Editor.blur method (#3875)
This PR adds a `editor.blur()` method to complement the `editor.focus()` method, and enhances both with an options param that allows to skip dispatching a focus/blur event on the container. ### Change Type <!-- ❗ Please select a 'Scope' label ❗️ --> - [x] `sdk` — Changes the tldraw SDK - [ ] `dotcom` — Changes the tldraw.com web app - [ ] `docs` — Changes to the documentation, examples, or templates. - [ ] `vs code` — Changes to the vscode plugin - [ ] `internal` — Does not affect user-facing stuff <!-- ❗ Please select a 'Type' label ❗️ --> - [ ] `bugfix` — Bug fix - [x] `feature` — New feature - [x] `improvement` — Improving existing features - [ ] `chore` — Updating dependencies, other boring stuff - [ ] `galaxy brain` — Architectural changes - [ ] `tests` — Changes to any test code - [ ] `tools` — Changes to infrastructure, CI, internal scripts, debugging tools, etc. - [ ] `dunno` — I don't know ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes - Add a brief release note for your PR here.
This commit is contained in:
parent
b7bc2dbbce
commit
5d58924f74
9 changed files with 156 additions and 39 deletions
|
@ -1,34 +1,41 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Editor, Tldraw } from 'tldraw'
|
import { Editor, Tldraw } from 'tldraw'
|
||||||
import 'tldraw/tldraw.css'
|
import 'tldraw/tldraw.css'
|
||||||
|
import './editor-focus.css'
|
||||||
|
|
||||||
export default function EditorFocusExample() {
|
export default function EditorFocusExample() {
|
||||||
const [focused, setFocused] = useState(false)
|
return (
|
||||||
const rEditorRef = useRef<Editor | null>(null)
|
<div style={{ padding: 32 }}>
|
||||||
|
<ControlledFocusExample />
|
||||||
|
<p>
|
||||||
|
You should be able to type in this text input without worrying about triggering editor
|
||||||
|
shortcuts even when the editor is focused.
|
||||||
|
</p>
|
||||||
|
<input type="text" placeholder="Test me" />
|
||||||
|
|
||||||
useEffect(() => {
|
<hr />
|
||||||
const editor = rEditorRef.current
|
<FreeFocusExample />
|
||||||
if (!editor) return
|
</div>
|
||||||
editor.updateInstanceState({ isFocused: focused })
|
)
|
||||||
}, [focused])
|
}
|
||||||
|
|
||||||
|
function ControlledFocusExample() {
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
style={{ padding: 32 }}
|
|
||||||
onPointerDown={() => {
|
|
||||||
const editor = rEditorRef.current
|
|
||||||
if (editor && editor.getInstanceState().isFocused) {
|
|
||||||
editor.updateInstanceState({ isFocused: false })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
|
<h2>Controlled Focus</h2>
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
id="focus"
|
id="focus"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setFocused(e.target.checked)
|
if (e.target.checked) {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
} else {
|
||||||
|
editorRef.current?.blur()
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="focus">Focus</label>
|
<label htmlFor="focus">Focus</label>
|
||||||
|
@ -45,11 +52,40 @@ export default function EditorFocusExample() {
|
||||||
<Tldraw
|
<Tldraw
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
rEditorRef.current = editor
|
editorRef.current = editor
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FreeFocusExample() {
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2>Free Focus</h2>
|
||||||
|
<p>
|
||||||
|
You can use `onBlur` and `onFocus` to control the editor's focus so that it behaves like a
|
||||||
|
native form input.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{ width: 800, maxWidth: '100%', height: 500 }}
|
||||||
|
onBlur={() => {
|
||||||
|
editorRef.current?.blur()
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
editorRef.current?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tldraw
|
||||||
|
autoFocus={false}
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Test me" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
3
apps/examples/src/examples/editor-focus/editor-focus.css
Normal file
3
apps/examples/src/examples/editor-focus/editor-focus.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.tl-container__focused {
|
||||||
|
outline: 1px solid var(--color-primary);
|
||||||
|
}
|
|
@ -36,9 +36,9 @@ const focusedEditorContext = createContext(
|
||||||
|
|
||||||
// [2]
|
// [2]
|
||||||
function blurEditor(editor: Editor) {
|
function blurEditor(editor: Editor) {
|
||||||
|
editor.blur()
|
||||||
editor.selectNone()
|
editor.selectNone()
|
||||||
editor.setCurrentTool('hand')
|
editor.setCurrentTool('hand')
|
||||||
editor.updateInstanceState({ isFocused: false })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InlineBehaviorExample() {
|
export default function InlineBehaviorExample() {
|
||||||
|
@ -81,7 +81,7 @@ function InlineBlock({ persistenceKey }: { persistenceKey: string }) {
|
||||||
if (focusedEditor && focusedEditor !== editor) {
|
if (focusedEditor && focusedEditor !== editor) {
|
||||||
blurEditor(focusedEditor)
|
blurEditor(focusedEditor)
|
||||||
}
|
}
|
||||||
editor.updateInstanceState({ isFocused: true })
|
editor.focus()
|
||||||
setFocusedEditor(editor)
|
setFocusedEditor(editor)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -19,9 +19,9 @@ export default function MultipleExample() {
|
||||||
const setFocusedEditor = useCallback(
|
const setFocusedEditor = useCallback(
|
||||||
(editor: Editor | null) => {
|
(editor: Editor | null) => {
|
||||||
if (focusedEditor !== editor) {
|
if (focusedEditor !== editor) {
|
||||||
focusedEditor?.updateInstanceState({ isFocused: false })
|
focusedEditor?.blur()
|
||||||
_setFocusedEditor(editor)
|
_setFocusedEditor(editor)
|
||||||
editor?.updateInstanceState({ isFocused: true })
|
editor?.focus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedEditor]
|
[focusedEditor]
|
||||||
|
|
|
@ -748,6 +748,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
bindingUtils: {
|
bindingUtils: {
|
||||||
readonly [K in string]?: BindingUtil<TLUnknownBinding>;
|
readonly [K in string]?: BindingUtil<TLUnknownBinding>;
|
||||||
};
|
};
|
||||||
|
blur({ blurContainer }?: {
|
||||||
|
blurContainer?: boolean | undefined;
|
||||||
|
}): this;
|
||||||
bringForward(shapes: TLShape[] | TLShapeId[]): this;
|
bringForward(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
|
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
|
@ -834,7 +837,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
findCommonAncestor(shapes: TLShape[] | TLShapeId[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined;
|
findCommonAncestor(shapes: TLShape[] | TLShapeId[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined;
|
||||||
findShapeAncestor(shape: TLShape | TLShapeId, predicate: (parent: TLShape) => boolean): TLShape | undefined;
|
findShapeAncestor(shape: TLShape | TLShapeId, predicate: (parent: TLShape) => boolean): TLShape | undefined;
|
||||||
flipShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
|
flipShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
|
||||||
focus(): this;
|
focus({ focusContainer }?: {
|
||||||
|
focusContainer?: boolean | undefined;
|
||||||
|
}): this;
|
||||||
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
|
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
|
||||||
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
|
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
|
||||||
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
|
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
|
||||||
|
@ -896,6 +901,8 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
getInitialMetaForShape(_shape: TLShape): JsonObject;
|
getInitialMetaForShape(_shape: TLShape): JsonObject;
|
||||||
getInitialZoom(): number;
|
getInitialZoom(): number;
|
||||||
getInstanceState(): TLInstance;
|
getInstanceState(): TLInstance;
|
||||||
|
// (undocumented)
|
||||||
|
getIsFocused(): boolean;
|
||||||
getIsMenuOpen(): boolean;
|
getIsMenuOpen(): boolean;
|
||||||
getOnlySelectedShape(): null | TLShape;
|
getOnlySelectedShape(): null | TLShape;
|
||||||
getOnlySelectedShapeId(): null | TLShapeId;
|
getOnlySelectedShapeId(): null | TLShapeId;
|
||||||
|
|
|
@ -237,7 +237,7 @@
|
||||||
-webkit-touch-callout: initial;
|
-webkit-touch-callout: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tl-container:focus-within {
|
.tl-container__focused {
|
||||||
outline: 1px solid var(--color-low);
|
outline: 1px solid var(--color-low);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8271,21 +8271,69 @@ export class Editor extends EventEmitter<TLEventMap> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a focus event.
|
* Puts the editor into focused mode.
|
||||||
|
*
|
||||||
|
* This makes the editor eligible to receive keyboard events and some pointer events (move, wheel).
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```ts
|
* ```ts
|
||||||
* editor.focus()
|
* editor.focus()
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
|
* By default this also dispatches a 'focus' event to the container element. To prevent this, pass `focusContainer: false`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* editor.focus({ focusContainer: false })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
focus(): this {
|
focus({ focusContainer = true } = {}): this {
|
||||||
this.focusManager.focus()
|
if (focusContainer) {
|
||||||
this.updateInstanceState({ isFocused: true }, { history: 'ignore' })
|
this.focusManager.focus()
|
||||||
|
}
|
||||||
|
this.updateInstanceState({ isFocused: true })
|
||||||
return this
|
return this
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches off the editor's focused mode.
|
||||||
|
*
|
||||||
|
* This makes the editor ignore keyboard events and some pointer events (move, wheel).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* editor.blur()
|
||||||
|
* ```
|
||||||
|
* By default this also dispatches a 'blur' event to the container element. To prevent this, pass `blurContainer: false`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* editor.blur({ blurContainer: false })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
blur({ blurContainer = true } = {}): this {
|
||||||
|
if (!this.getIsFocused()) return this
|
||||||
|
if (blurContainer) {
|
||||||
|
this.focusManager.blur()
|
||||||
|
} else {
|
||||||
|
this.complete() // stop any interaction
|
||||||
|
}
|
||||||
|
this.updateInstanceState({ isFocused: false })
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
* @returns true if the editor is focused
|
||||||
|
*/
|
||||||
|
@computed getIsFocused() {
|
||||||
|
return this.getInstanceState().isFocused
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A manager for recording multiple click events.
|
* A manager for recording multiple click events.
|
||||||
*
|
*
|
||||||
|
|
|
@ -21,6 +21,7 @@ export class FocusManager {
|
||||||
(prev, next) => {
|
(prev, next) => {
|
||||||
if (prev.isFocused !== next.isFocused) {
|
if (prev.isFocused !== next.isFocused) {
|
||||||
next.isFocused ? this.focus() : this.blur()
|
next.isFocused ? this.focus() : this.blur()
|
||||||
|
this.updateContainerClass()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -29,6 +30,27 @@ export class FocusManager {
|
||||||
if (autoFocus !== currentFocusState) {
|
if (autoFocus !== currentFocusState) {
|
||||||
editor.updateInstanceState({ isFocused: !!autoFocus })
|
editor.updateInstanceState({ isFocused: !!autoFocus })
|
||||||
}
|
}
|
||||||
|
this.updateContainerClass()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The editor's focus state and the container's focus state
|
||||||
|
* are not necessarily always in sync. For that reason we
|
||||||
|
* can't rely on the css `:focus` or `:focus-within` selectors to style the
|
||||||
|
* editor when it is in focus.
|
||||||
|
*
|
||||||
|
* For that reason we synchronize the editor's focus state with a
|
||||||
|
* special class on the container: tl-container__focused
|
||||||
|
*/
|
||||||
|
private updateContainerClass() {
|
||||||
|
const container = this.editor.getContainer()
|
||||||
|
const instanceState = this.editor.getInstanceState()
|
||||||
|
|
||||||
|
if (instanceState.isFocused) {
|
||||||
|
container.classList.add('tl-container__focused')
|
||||||
|
} else {
|
||||||
|
container.classList.remove('tl-container__focused')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
|
|
|
@ -22,22 +22,23 @@ export function useKeyboardShortcuts() {
|
||||||
const actions = useActions()
|
const actions = useActions()
|
||||||
const tools = useTools()
|
const tools = useTools()
|
||||||
const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])
|
const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFocused) return
|
if (!isFocused) return
|
||||||
|
|
||||||
hotkeys.setScope(editor.store.id)
|
const disposables = new Array<() => void>()
|
||||||
|
|
||||||
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
|
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
|
||||||
hotkeys(keys, { element: document.body, scope: editor.store.id }, callback)
|
hotkeys(keys, { element: document.body }, callback)
|
||||||
|
disposables.push(() => {
|
||||||
|
hotkeys.unbind(keys, callback)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const hotUp = (keys: string, callback: (event: KeyboardEvent) => void) => {
|
const hotUp = (keys: string, callback: (event: KeyboardEvent) => void) => {
|
||||||
hotkeys(
|
hotkeys(keys, { element: document.body, keyup: true, keydown: false }, callback)
|
||||||
keys,
|
disposables.push(() => {
|
||||||
{ element: document.body, keyup: true, keydown: false, scope: editor.store.id },
|
hotkeys.unbind(keys, callback)
|
||||||
callback
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add hotkeys for actions and tools.
|
// Add hotkeys for actions and tools.
|
||||||
|
@ -121,7 +122,7 @@ export function useKeyboardShortcuts() {
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
hotkeys.deleteScope(editor.store.id)
|
disposables.forEach((d) => d())
|
||||||
}
|
}
|
||||||
}, [actions, tools, isReadonlyMode, editor, isFocused])
|
}, [actions, tools, isReadonlyMode, editor, isFocused])
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue