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:
David Sheldrick 2024-06-05 13:25:41 +01:00 committed by GitHub
parent b7bc2dbbce
commit 5d58924f74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 156 additions and 39 deletions

View file

@ -1,34 +1,41 @@
import { useEffect, useRef, useState } from 'react'
import { useRef } from 'react'
import { Editor, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
import './editor-focus.css'
export default function EditorFocusExample() {
const [focused, setFocused] = useState(false)
const rEditorRef = useRef<Editor | null>(null)
return (
<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(() => {
const editor = rEditorRef.current
if (!editor) return
editor.updateInstanceState({ isFocused: focused })
}, [focused])
<hr />
<FreeFocusExample />
</div>
)
}
function ControlledFocusExample() {
const editorRef = useRef<Editor | null>(null)
return (
<div
style={{ padding: 32 }}
onPointerDown={() => {
const editor = rEditorRef.current
if (editor && editor.getInstanceState().isFocused) {
editor.updateInstanceState({ isFocused: false })
}
}}
>
<>
<div>
<h2>Controlled Focus</h2>
<div style={{ display: 'flex', gap: 4 }}>
<input
id="focus"
type="checkbox"
onChange={(e) => {
setFocused(e.target.checked)
if (e.target.checked) {
editorRef.current?.focus()
} else {
editorRef.current?.blur()
}
}}
/>
<label htmlFor="focus">Focus</label>
@ -45,11 +52,40 @@ export default function EditorFocusExample() {
<Tldraw
autoFocus={false}
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>
<input type="text" placeholder="Test me" />
</div>
)
}

View file

@ -0,0 +1,3 @@
.tl-container__focused {
outline: 1px solid var(--color-primary);
}

View file

@ -36,9 +36,9 @@ const focusedEditorContext = createContext(
// [2]
function blurEditor(editor: Editor) {
editor.blur()
editor.selectNone()
editor.setCurrentTool('hand')
editor.updateInstanceState({ isFocused: false })
}
export default function InlineBehaviorExample() {
@ -81,7 +81,7 @@ function InlineBlock({ persistenceKey }: { persistenceKey: string }) {
if (focusedEditor && focusedEditor !== editor) {
blurEditor(focusedEditor)
}
editor.updateInstanceState({ isFocused: true })
editor.focus()
setFocusedEditor(editor)
}}
>

View file

@ -19,9 +19,9 @@ export default function MultipleExample() {
const setFocusedEditor = useCallback(
(editor: Editor | null) => {
if (focusedEditor !== editor) {
focusedEditor?.updateInstanceState({ isFocused: false })
focusedEditor?.blur()
_setFocusedEditor(editor)
editor?.updateInstanceState({ isFocused: true })
editor?.focus()
}
},
[focusedEditor]

View file

@ -748,6 +748,9 @@ export class Editor extends EventEmitter<TLEventMap> {
bindingUtils: {
readonly [K in string]?: BindingUtil<TLUnknownBinding>;
};
blur({ blurContainer }?: {
blurContainer?: boolean | undefined;
}): this;
bringForward(shapes: TLShape[] | TLShapeId[]): this;
bringToFront(shapes: TLShape[] | TLShapeId[]): this;
// (undocumented)
@ -834,7 +837,9 @@ export class Editor extends EventEmitter<TLEventMap> {
findCommonAncestor(shapes: TLShape[] | TLShapeId[], predicate?: (shape: TLShape) => boolean): TLShapeId | undefined;
findShapeAncestor(shape: TLShape | TLShapeId, predicate: (parent: TLShape) => boolean): TLShape | undefined;
flipShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
focus(): this;
focus({ focusContainer }?: {
focusContainer?: boolean | undefined;
}): this;
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
@ -896,6 +901,8 @@ export class Editor extends EventEmitter<TLEventMap> {
getInitialMetaForShape(_shape: TLShape): JsonObject;
getInitialZoom(): number;
getInstanceState(): TLInstance;
// (undocumented)
getIsFocused(): boolean;
getIsMenuOpen(): boolean;
getOnlySelectedShape(): null | TLShape;
getOnlySelectedShapeId(): null | TLShapeId;

View file

@ -237,7 +237,7 @@
-webkit-touch-callout: initial;
}
.tl-container:focus-within {
.tl-container__focused {
outline: 1px solid var(--color-low);
}

View file

@ -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
* ```ts
* 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
*/
focus(): this {
this.focusManager.focus()
this.updateInstanceState({ isFocused: true }, { history: 'ignore' })
focus({ focusContainer = true } = {}): this {
if (focusContainer) {
this.focusManager.focus()
}
this.updateInstanceState({ isFocused: true })
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.
*

View file

@ -21,6 +21,7 @@ export class FocusManager {
(prev, next) => {
if (prev.isFocused !== next.isFocused) {
next.isFocused ? this.focus() : this.blur()
this.updateContainerClass()
}
}
)
@ -29,6 +30,27 @@ export class FocusManager {
if (autoFocus !== currentFocusState) {
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() {

View file

@ -22,22 +22,23 @@ export function useKeyboardShortcuts() {
const actions = useActions()
const tools = useTools()
const isFocused = useValue('is focused', () => editor.getInstanceState().isFocused, [editor])
useEffect(() => {
if (!isFocused) return
hotkeys.setScope(editor.store.id)
const disposables = new Array<() => 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) => {
hotkeys(
keys,
{ element: document.body, keyup: true, keydown: false, scope: editor.store.id },
callback
)
hotkeys(keys, { element: document.body, keyup: true, keydown: false }, callback)
disposables.push(() => {
hotkeys.unbind(keys, callback)
})
}
// Add hotkeys for actions and tools.
@ -121,7 +122,7 @@ export function useKeyboardShortcuts() {
})
return () => {
hotkeys.deleteScope(editor.store.id)
disposables.forEach((d) => d())
}
}, [actions, tools, isReadonlyMode, editor, isFocused])
}