focus: rework and untangle existing focus management logic in the sdk (#3718)

Focus management is really scattered across the codebase. There's sort
of a battle between different code paths to make the focus the correct
desired state. It seemed to grow like a knot and once I started pulling
on one thread to see if it was still needed you could see underneath
that it was accounting for another thing underneath that perhaps wasn't
needed.

The impetus for this PR came but especially during the text label
rework, now that it's much more easy to jump around from textfield to
textfield. It became apparent that we were playing whack-a-mole trying
to preserve the right focus conditions (especially on iOS, ugh).

This tries to remove as many hacks as possible, and bring together in
place the focus logic (and in the darkness, bind them).

## Places affected
- [x] `useEditableText`: was able to remove a bunch of the focus logic
here. In addition, it doesn't look like we need to save the selection
range anymore.
- lingering footgun that needed to be fixed anyway: if there are two
labels in the same shape, because we were just checking `editingShapeId
=== id`, the two text labels would have just fought each other for
control
- [x] `useFocusEvents`: nixed and refactored — we listen to the store in
`FocusManager` and then take care of autoFocus there
- [x] `useSafariFocusOutFix`: nixed. not necessary anymore because we're
not trying to refocus when blurring in `useEditableText`. original PR
for reference: https://github.com/tldraw/brivate/pull/79
- [x] `defaultSideEffects`: moved logic to `FocusManager`
- [x] `PointingShape` focus for `startTranslating`, decided to leave
this alone actually.
- [x] `TldrawUIButton`: it doesn't look like this focus bug fix is
needed anymore, original PR for reference:
https://github.com/tldraw/tldraw/pull/2630
- [x] `useDocumentEvents`: left alone its manual focus after the Escape
key is hit
- [x] `FrameHeading`: double focus/select doesn't seem necessary anymore
- [x] `useCanvasEvents`: `onPointerDown` focus logic never happened b/c
in `Editor.ts` we `clearedMenus` on pointer down
- [x] `onTouchStart`: looks like `document.body.click()` is not
necessary anymore

## Future Changes
- [ ] a11y: work on having an accessebility focus ring
- [ ] Page visibility API:
(https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API)
events when tab is back in focus vs. background, different kind of focus
- [ ] Reexamine places we manually dispatch `pointer_down` events to see
if they're necessary.
- [ ] Minor: get rid of `useContainer` maybe? Is it really necessary to
have this hook? you can just do `useEditor` → `editor.getContainer()`,
feels superfluous.

## Methodology
Looked for places where we do:
- `body.click()`
- places we do `container.focus()`
- places we do `container.blur()`
- places we do `editor.updateInstanceState({ isFocused })`
- places we do `autofocus`
- searched for `document.activeElement`

### 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
- [ ] `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

- [x] run test-focus.spec.ts
- [x] check MultipleExample
- [x] check EditorFocusExample
- [x] check autoFocus
- [x] check style panel usage and focus events in general
- [x] check text editing focus, lots of different devices,
mobile/desktop

### Release Notes

- Focus: rework and untangle existing focus management logic in the SDK
This commit is contained in:
Mime Čuvalo 2024-05-17 09:53:57 +01:00 committed by GitHub
parent 48fa9018f4
commit b4c1f606e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 235 additions and 284 deletions

View file

@ -67,7 +67,6 @@ export function BoardHistorySnapshot({
}}
overrides={[fileSystemUiOverrides]}
inferDarkMode
autoFocus
/>
</div>
<div className="board-history__restore">

View file

@ -9,7 +9,7 @@ import {
useRef,
useState,
} from 'react'
import { preventDefault, track, useContainer, useEditor, useTranslation } from 'tldraw'
import { preventDefault, track, useEditor, useTranslation } from 'tldraw'
// todo:
// - not cleaning up
@ -18,7 +18,6 @@ const CHAT_MESSAGE_TIMEOUT_CHATTING = 5000
export const CursorChatBubble = track(function CursorChatBubble() {
const editor = useEditor()
const container = useContainer()
const { isChatting, chatMessage } = editor.getInstanceState()
const rTimeout = useRef<any>(-1)
@ -31,14 +30,14 @@ export const CursorChatBubble = track(function CursorChatBubble() {
rTimeout.current = setTimeout(() => {
editor.updateInstanceState({ chatMessage: '', isChatting: false })
setValue('')
container.focus()
editor.focus()
}, duration)
}
return () => {
clearTimeout(rTimeout.current)
}
}, [container, editor, chatMessage, isChatting])
}, [editor, chatMessage, isChatting])
if (isChatting)
return <CursorChatInput value={value} setValue={setValue} chatMessage={chatMessage} />
@ -101,7 +100,6 @@ const CursorChatInput = track(function CursorChatInput({
}) {
const editor = useEditor()
const msg = useTranslation()
const container = useContainer()
const ref = useRef<HTMLInputElement>(null)
const placeholder = chatMessage || msg('cursor-chat.type-to-chat')
@ -126,11 +124,9 @@ const CursorChatInput = track(function CursorChatInput({
}, [editor, value, placeholder])
useLayoutEffect(() => {
// Focus the editor
let raf = requestAnimationFrame(() => {
raf = requestAnimationFrame(() => {
ref.current?.focus()
})
// Focus the input
const raf = requestAnimationFrame(() => {
ref.current?.focus()
})
return () => {
@ -140,8 +136,8 @@ const CursorChatInput = track(function CursorChatInput({
const stopChatting = useCallback(() => {
editor.updateInstanceState({ isChatting: false })
container.focus()
}, [editor, container])
editor.focus()
}, [editor])
// Update the chat message as the user types
const handleChange = useCallback(

View file

@ -102,7 +102,6 @@ export function LocalEditor() {
assetUrls={assetUrls}
persistenceKey={SCRATCH_PERSISTENCE_KEY}
onMount={handleMount}
autoFocus
overrides={[sharingUiOverrides, fileSystemUiOverrides]}
onUiEvent={handleUiEvent}
components={components}

View file

@ -158,7 +158,6 @@ export function MultiplayerEditor({
initialState={isReadonly ? 'hand' : 'select'}
onUiEvent={handleUiEvent}
components={components}
autoFocus
inferDarkMode
>
<UrlStateSync />

View file

@ -52,8 +52,8 @@ export function UserPresenceEditor() {
onCancel={toggleEditingName}
onBlur={handleBlur}
shouldManuallyMaintainScrollPositionWhenFocused
autofocus
autoselect
autoFocus
autoSelect
/>
) : (
<>

View file

@ -89,7 +89,6 @@ export function SnapshotsEditor(props: SnapshotEditorProps) {
}}
components={components}
renderDebugMenuItems={() => <DebugMenuItems />}
autoFocus
inferDarkMode
>
<UrlStateSync />

View file

@ -175,4 +175,51 @@ test.describe('Focus', () => {
null
)
})
test('still focuses text after clicking on style button', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
// Create a new note, text should be focused
await page.keyboard.press('n')
await (await page.$('body'))?.click()
await page.waitForSelector('.tl-shape')
const blueButton = await page.$('.tlui-button[data-testid="style.color.blue"]')
await blueButton?.dispatchEvent('pointerdown')
await blueButton?.click()
await blueButton?.dispatchEvent('pointerup')
// Text should still be focused.
expect(await page.evaluate(() => document.activeElement?.nodeName === 'TEXTAREA')).toBe(true)
})
test('edit->edit, focus stays in the text areas when going from shape-to-shape', async ({
page,
}) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.tl-container`))!
expect(EditorA).toBeTruthy()
// Create a new note, text should be focused
await page.keyboard.press('n')
await (await page.$('body'))?.click()
await page.waitForSelector('.tl-shape')
await page.keyboard.type('test')
// create new note next to it
await page.keyboard.press('Tab')
await (await page.$('body'))?.click()
// First note's textarea should be focused.
expect(await EditorA.evaluate(() => !!document.querySelector('.tl-shape textarea:focus'))).toBe(
true
)
})
})

View file

@ -67,7 +67,7 @@ export default function ExternalContentSourcesExample() {
return (
<div className="tldraw__editor">
<Tldraw autoFocus onMount={handleMount} shapeUtils={[DangerousHtmlExample]} />
<Tldraw onMount={handleMount} shapeUtils={[DangerousHtmlExample]} />
</div>
)
}

View file

@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useState } from 'react'
import { Tldraw } from 'tldraw'
import { Editor, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css'
// There's a guide at the bottom of this page!
@ -7,24 +7,35 @@ import 'tldraw/tldraw.css'
// [1]
const focusedEditorContext = createContext(
{} as {
focusedEditor: string | null
setFocusedEditor: (id: string | null) => void
focusedEditor: Editor | null
setFocusedEditor: (id: Editor | null) => void
}
)
// [2]
export default function MultipleExample() {
const [focusedEditor, _setFocusedEditor] = useState<string | null>('A')
const [focusedEditor, _setFocusedEditor] = useState<Editor | null>(null)
const setFocusedEditor = useCallback(
(id: string | null) => {
if (focusedEditor !== id) {
_setFocusedEditor(id)
(editor: Editor | null) => {
if (focusedEditor !== editor) {
focusedEditor?.updateInstanceState({ isFocused: false })
_setFocusedEditor(editor)
editor?.updateInstanceState({ isFocused: true })
}
},
[focusedEditor]
)
const focusName =
focusedEditor === (window as any).EDITOR_A
? 'A'
: focusedEditor === (window as any).EDITOR_B
? 'B'
: focusedEditor === (window as any).EDITOR_C
? 'C'
: 'none'
return (
<div
style={{
@ -35,7 +46,7 @@ export default function MultipleExample() {
onPointerDown={() => setFocusedEditor(null)}
>
<focusedEditorContext.Provider value={{ focusedEditor, setFocusedEditor }}>
<h1>Focusing: {focusedEditor ?? 'none'}</h1>
<h1>Focusing: {focusName}</h1>
<EditorA />
<textarea data-testid="textarea" placeholder="type in me" style={{ margin: 10 }} />
<div
@ -61,19 +72,23 @@ export default function MultipleExample() {
// [3]
function EditorA() {
const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext)
const isFocused = focusedEditor === 'A'
const { setFocusedEditor } = useContext(focusedEditorContext)
return (
<div style={{ padding: 32 }}>
<h2>A</h2>
<div tabIndex={-1} onFocus={() => setFocusedEditor('A')} style={{ height: 600 }}>
<div
tabIndex={-1}
onFocus={() => setFocusedEditor((window as any).EDITOR_A)}
style={{ height: 600 }}
>
<Tldraw
persistenceKey="steve"
className="A"
autoFocus={isFocused}
autoFocus={false}
onMount={(editor) => {
;(window as any).EDITOR_A = editor
setFocusedEditor(editor)
}}
/>
</div>
@ -83,17 +98,20 @@ function EditorA() {
// [4]
function EditorB() {
const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext)
const isFocused = focusedEditor === 'B'
const { setFocusedEditor } = useContext(focusedEditorContext)
return (
<div>
<h2>B</h2>
<div tabIndex={-1} onFocus={() => setFocusedEditor('B')} style={{ height: 600 }}>
<div
tabIndex={-1}
onFocus={() => setFocusedEditor((window as any).EDITOR_B)}
style={{ height: 600 }}
>
<Tldraw
persistenceKey="david"
className="B"
autoFocus={isFocused}
autoFocus={false}
onMount={(editor) => {
;(window as any).EDITOR_B = editor
}}
@ -104,17 +122,20 @@ function EditorB() {
}
function EditorC() {
const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext)
const isFocused = focusedEditor === 'C'
const { setFocusedEditor } = useContext(focusedEditorContext)
return (
<div>
<h2>C</h2>
<div tabIndex={-1} onFocus={() => setFocusedEditor('C')} style={{ height: 600 }}>
<div
tabIndex={-1}
onFocus={() => setFocusedEditor((window as any).EDITOR_C)}
style={{ height: 600 }}
>
<Tldraw
persistenceKey="david"
className="C"
autoFocus={isFocused}
autoFocus={false}
onMount={(editor) => {
;(window as any).EDITOR_C = editor
}}

View file

@ -14,10 +14,7 @@ export default function ScrollExample() {
}}
>
<div style={{ width: '60vw', height: '80vh' }}>
<Tldraw
persistenceKey="scroll-example"
// autoFocus={false}
/>
<Tldraw persistenceKey="scroll-example" />
</div>
</div>
)

View file

@ -126,7 +126,6 @@ function TldrawInner({ uri, assetSrc, isDarkMode, fileContents }: TLDrawInnerPro
persistenceKey={uri}
onMount={handleMount}
components={components}
autoFocus
>
{/* <DarkModeHandler themeKind={themeKind} /> */}

View file

@ -666,7 +666,7 @@ export class Edge2d extends Geometry2d {
// @public (undocumented)
export class Editor extends EventEmitter<TLEventMap> {
constructor({ store, user, shapeUtils, bindingUtils, tools, getContainer, cameraOptions, initialState, inferDarkMode, }: TLEditorOptions);
constructor({ store, user, shapeUtils, bindingUtils, tools, getContainer, cameraOptions, initialState, autoFocus, inferDarkMode, }: TLEditorOptions);
addOpenMenu(id: string): this;
alignShapes(shapes: TLShape[] | TLShapeId[], operation: 'bottom' | 'center-horizontal' | 'center-vertical' | 'left' | 'right' | 'top'): this;
animateShape(partial: null | TLShapePartial | undefined, opts?: Partial<{
@ -774,6 +774,7 @@ 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;
getAncestorPageId(shape?: TLShape | TLShapeId): TLPageId | undefined;
getAsset(asset: TLAsset | TLAssetId): TLAsset | undefined;
getAssetForExternalContent(info: TLExternalAssetContent): Promise<TLAsset | undefined>;
@ -2201,6 +2202,7 @@ export type TLEditorComponents = Partial<{
// @public (undocumented)
export interface TLEditorOptions {
autoFocus?: boolean;
bindingUtils: readonly TLBindingUtilConstructor<TLUnknownBinding>[];
cameraOptions?: Partial<TLCameraOptions>;
getContainer: () => HTMLElement;

View file

@ -30,10 +30,8 @@ import {
useEditorComponents,
} from './hooks/useEditorComponents'
import { useEvent } from './hooks/useEvent'
import { useFocusEvents } from './hooks/useFocusEvents'
import { useForceUpdate } from './hooks/useForceUpdate'
import { useLocalStore } from './hooks/useLocalStore'
import { useSafariFocusOutFix } from './hooks/useSafariFocusOutFix'
import { useZoomCss } from './hooks/useZoomCss'
import { stopEventPropagation } from './utils/dom'
import { TLStoreWithStatus } from './utils/sync/StoreWithStatus'
@ -305,6 +303,7 @@ function TldrawEditorWithReadyStore({
const { ErrorFallback } = useEditorComponents()
const container = useContainer()
const [editor, setEditor] = useState<Editor | null>(null)
const [initialAutoFocus] = useState(autoFocus)
useLayoutEffect(() => {
const editor = new Editor({
@ -315,6 +314,7 @@ function TldrawEditorWithReadyStore({
getContainer: () => container,
user,
initialState,
autoFocus: initialAutoFocus,
inferDarkMode,
cameraOptions,
})
@ -331,6 +331,7 @@ function TldrawEditorWithReadyStore({
store,
user,
initialState,
initialAutoFocus,
inferDarkMode,
cameraOptions,
])
@ -374,30 +375,18 @@ function TldrawEditorWithReadyStore({
<Crash crashingError={crashingError} />
) : (
<EditorContext.Provider value={editor}>
<Layout autoFocus={autoFocus} onMount={onMount}>
{children ?? (Canvas ? <Canvas /> : null)}
</Layout>
<Layout onMount={onMount}>{children ?? (Canvas ? <Canvas /> : null)}</Layout>
</EditorContext.Provider>
)}
</OptionalErrorBoundary>
)
}
function Layout({
children,
onMount,
autoFocus,
}: {
children: ReactNode
autoFocus: boolean
onMount?: TLOnMountHandler
}) {
function Layout({ children, onMount }: { children: ReactNode; onMount?: TLOnMountHandler }) {
useZoomCss()
useCursor()
useDarkMode()
useSafariFocusOutFix()
useForceUpdate()
useFocusEvents(autoFocus)
useOnMount(onMount)
return (

View file

@ -131,6 +131,7 @@ import { deriveShapeIdsInCurrentPage } from './derivations/shapeIdsInCurrentPage
import { getSvgJsx } from './getSvgJsx'
import { ClickManager } from './managers/ClickManager'
import { EnvironmentManager } from './managers/EnvironmentManager'
import { FocusManager } from './managers/FocusManager'
import { HistoryManager } from './managers/HistoryManager'
import { ScribbleManager } from './managers/ScribbleManager'
import { SnapManager } from './managers/SnapManager/SnapManager'
@ -203,6 +204,10 @@ export interface TLEditorOptions {
* The editor's initial active tool (or other state node id).
*/
initialState?: string
/**
* Whether to automatically focus the editor when it mounts.
*/
autoFocus?: boolean
/**
* Whether to infer dark mode from the user's system preferences. Defaults to false.
*/
@ -224,6 +229,7 @@ export class Editor extends EventEmitter<TLEventMap> {
getContainer,
cameraOptions,
initialState,
autoFocus,
inferDarkMode,
}: TLEditorOptions) {
super()
@ -677,6 +683,9 @@ export class Editor extends EventEmitter<TLEventMap> {
this.root.enter(undefined, 'initial')
this.focusManager = new FocusManager(this, autoFocus)
this.disposables.add(this.focusManager.dispose.bind(this.focusManager))
if (this.getInstanceState().followingUserId) {
this.stopFollowingUser()
}
@ -756,6 +765,13 @@ export class Editor extends EventEmitter<TLEventMap> {
*/
readonly sideEffects: StoreSideEffects<TLRecord>
/**
* A manager for ensuring correct focus. See FocusManager for details.
*
* @internal
*/
private focusManager: FocusManager
/**
* The current HTML element containing the editor.
*
@ -8066,6 +8082,21 @@ export class Editor extends EventEmitter<TLEventMap> {
return this
}
/**
* Dispatch a focus event.
*
* @example
* ```ts
* editor.focus()
* ```
*
* @public
*/
focus(): this {
this.focusManager.focus()
return this
}
/**
* A manager for recording multiple click events.
*

View file

@ -0,0 +1,46 @@
import type { Editor } from '../Editor'
/**
* A manager for ensuring correct focus across the editor.
* It will listen for changes in the instance state to make sure the
* container is focused when the editor is focused.
* Also, it will make sure that the focus is on things like text
* labels when the editor is in editing mode.
*
* @internal
*/
export class FocusManager {
private disposeSideEffectListener?: () => void
constructor(
public editor: Editor,
autoFocus?: boolean
) {
this.disposeSideEffectListener = editor.sideEffects.registerAfterChangeHandler(
'instance',
(prev, next) => {
if (prev.isFocused !== next.isFocused) {
next.isFocused ? this.focus() : this.blur()
}
}
)
const currentFocusState = editor.getInstanceState().isFocused
if (autoFocus !== currentFocusState) {
editor.updateInstanceState({ isFocused: !!autoFocus })
}
}
focus() {
this.editor.getContainer().focus()
}
blur() {
this.editor.complete() // stop any interaction
this.editor.getContainer().blur() // blur the container
}
dispose() {
this.disposeSideEffectListener?.()
}
}

View file

@ -40,15 +40,6 @@ export function useCanvasEvents() {
name: 'pointer_down',
...getPointerInfo(e),
})
if (editor.getOpenMenus().length > 0) {
editor.updateInstanceState({
openMenus: [],
})
document.body.click()
editor.getContainer().focus()
}
}
function onPointerMove(e: React.PointerEvent) {
@ -98,9 +89,6 @@ export function useCanvasEvents() {
function onTouchStart(e: React.TouchEvent) {
;(e as any).isKilled = true
// todo: investigate whether this effects keyboard shortcuts
// god damn it, but necessary for long presses to open the context menu
document.body.click()
preventDefault(e)
}

View file

@ -125,7 +125,7 @@ export function useDocumentEvents() {
// will break additional shortcuts. We need to
// refocus the container in order to keep these
// shortcuts working.
container.focus()
editor.focus()
}
return
}

View file

@ -1,32 +0,0 @@
import { useLayoutEffect } from 'react'
import { useContainer } from './useContainer'
import { useEditor } from './useEditor'
/** @internal */
export function useFocusEvents(autoFocus: boolean) {
const editor = useEditor()
const container = useContainer()
useLayoutEffect(() => {
if (autoFocus) {
// When autoFocus is true, update the editor state to be focused
// unless it's already focused
if (!editor.getInstanceState().isFocused) {
editor.updateInstanceState({ isFocused: true })
}
// Note: Focus is also handled by the side effect manager in tldraw.
// Importantly, if a user manually sets isFocused to true (or if it
// changes for any reason from false to true), the side effect manager
// in tldraw will also take care of the focus. However, it may be that
// on first mount the editor already has isFocused: true in the model,
// so we also need to focus it here just to be sure.
editor.getContainer().focus()
} else {
// When autoFocus is false, update the editor state to be not focused
// unless it's already not focused
if (editor.getInstanceState().isFocused) {
editor.updateInstanceState({ isFocused: false })
}
}
}, [editor, container, autoFocus])
}

View file

@ -1,32 +0,0 @@
import * as React from 'react'
import { useEditor } from './useEditor'
let isMobileSafari = false
if (typeof window !== 'undefined') {
const ua = window.navigator.userAgent
const iOS = !!ua.match(/iPad/i) || !!ua.match(/iPhone/i)
const webkit = !!ua.match(/WebKit/i)
isMobileSafari = iOS && webkit && !ua.match(/CriOS/i)
}
export function useSafariFocusOutFix(): void {
const editor = useEditor()
React.useEffect(() => {
if (!isMobileSafari) return
function handleFocusOut(e: FocusEvent) {
if (
(e.target instanceof HTMLInputElement && e.target.type === 'text') ||
e.target instanceof HTMLTextAreaElement
) {
editor.complete()
}
}
// Send event on iOS when a user presses the "Done" key while editing a text element.
document.addEventListener('focusout', handleFocusOut)
return () => document.removeEventListener('focusout', handleFocusOut)
}, [editor])
}

View file

@ -8,7 +8,7 @@ export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['cre
/**
* Defines the scope of the record
*
* instance: The record belongs to a single instance of the store. It should not be synced, and any persistence logic should 'de-instance-ize' the record before persisting it, and apply the reverse when rehydrating.
* session: The record belongs to a single instance of the store. It should not be synced, and any persistence logic should 'de-instance-ize' the record before persisting it, and apply the reverse when rehydrating.
* document: The record is persisted and synced. It is available to all store instances.
* presence: The record belongs to a single instance of the store. It may be synced to other instances, but other instances should not make changes to it. It should not be persisted.
*

View file

@ -2220,9 +2220,9 @@ export type TLUiIconType = 'align-bottom' | 'align-center-horizontal' | 'align-c
// @public (undocumented)
export interface TLUiInputProps {
// (undocumented)
autofocus?: boolean;
autoFocus?: boolean;
// (undocumented)
autoselect?: boolean;
autoSelect?: boolean;
// (undocumented)
children?: React_3.ReactNode;
// (undocumented)
@ -2611,7 +2611,7 @@ export function useDialogs(): TLUiDialogsContextType;
// @public (undocumented)
export function useEditableText(id: TLShapeId, type: string, text: string): {
handleBlur: () => void;
handleBlur: typeof noop;
handleChange: (e: React_2.ChangeEvent<HTMLTextAreaElement>) => void;
handleDoubleClick: (e: any) => any;
handleFocus: typeof noop;

View file

@ -2,16 +2,6 @@ import { Editor } from '@tldraw/editor'
export function registerDefaultSideEffects(editor: Editor) {
return [
editor.sideEffects.registerAfterChangeHandler('instance', (prev, next) => {
if (prev.isFocused !== next.isFocused) {
if (next.isFocused) {
editor.getContainer().focus()
} else {
editor.complete() // stop any interaction
editor.getContainer().blur() // blur the container
}
}
}),
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny(

View file

@ -58,14 +58,6 @@ export const FrameHeading = function FrameHeading({
// On iOS, we must focus here
el.focus()
el.select()
requestAnimationFrame(() => {
// On desktop, the input may have lost focus, so try try try again!
if (document.activeElement !== el) {
el.focus()
el.select()
}
})
}
}, [rInput, isEditing])

View file

@ -13,7 +13,6 @@ import { INDENT, TextHelpers } from './TextHelpers'
export function useEditableText(id: TLShapeId, type: string, text: string) {
const editor = useEditor()
const rInput = useRef<HTMLTextAreaElement>(null)
const rSelectionRanges = useRef<Range[] | null>()
const isEditing = useValue('isEditing', () => editor.getEditingShapeId() === id, [editor])
const isEditingAnything = useValue('isEditingAnything', () => !!editor.getEditingShapeId(), [
editor,
@ -21,98 +20,36 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
useEffect(() => {
function selectAllIfEditing({ shapeId }: { shapeId: TLShapeId }) {
// We wait a tick, because on iOS, the keyboard will not show if we focus immediately.
requestAnimationFrame(() => {
if (shapeId === id) {
const elm = rInput.current
if (elm) {
if (document.activeElement !== elm) {
elm.focus()
}
elm.select()
}
}
})
if (shapeId === id) {
rInput.current?.select()
}
}
editor.on('select-all-text', selectAllIfEditing)
return () => {
editor.off('select-all-text', selectAllIfEditing)
}
}, [editor, id])
}, [editor, id, isEditing])
useEffect(() => {
if (!isEditing) return
const elm = rInput.current
if (!elm) return
// Focus if we're not already focused
if (document.activeElement !== elm) {
elm.focus()
// On mobile etc, just select all the text when we start focusing
if (editor.getInstanceState().isCoarsePointer) {
elm.select()
}
} else {
// This fixes iOS not showing the cursor sometimes. This "shakes" the cursor
// awake.
if (editor.environment.isSafari) {
elm.blur()
elm.focus()
}
if (document.activeElement !== rInput.current) {
rInput.current?.focus()
}
// When the selection changes, save the selection ranges
function updateSelection() {
const selection = window.getSelection?.()
if (selection && selection.type !== 'None') {
const ranges: Range[] = []
for (let i = 0; i < selection.rangeCount; i++) {
ranges.push(selection.getRangeAt?.(i))
}
rSelectionRanges.current = ranges
}
if (editor.getInstanceState().isCoarsePointer) {
rInput.current?.select()
}
document.addEventListener('selectionchange', updateSelection)
return () => {
document.removeEventListener('selectionchange', updateSelection)
// XXX(mime): This fixes iOS not showing the cursor sometimes.
// This "shakes" the cursor awake.
if (editor.environment.isSafari) {
rInput.current?.blur()
rInput.current?.focus()
}
}, [editor, isEditing])
// 2. Restore the selection changes (and focus) if the element blurs
// When the label blurs, deselect all of the text and complete.
// This makes it so that the canvas does not have to be focused
// in order to exit the editing state and complete the editing state
const handleBlur = useCallback(() => {
const ranges = rSelectionRanges.current
requestAnimationFrame(() => {
const elm = rInput.current
const editingShapeId = editor.getEditingShapeId()
// Did we move to a different shape?
if (editingShapeId) {
// important! these ^v are two different things
// is that shape OUR shape?
if (elm && editingShapeId === id) {
elm.focus()
if (ranges && ranges.length) {
const selection = window.getSelection()
if (selection) {
ranges.forEach((range) => selection.addRange(range))
}
}
}
} else {
window.getSelection()?.removeAllRanges()
}
})
}, [editor, id])
// When the user presses ctrl / meta enter, complete the editing state.
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@ -186,7 +123,7 @@ export function useEditableText(id: TLShapeId, type: string, text: string) {
return {
rInput,
handleFocus: noop,
handleBlur,
handleBlur: noop,
handleKeyDown,
handleChange,
handleInputPointerDown,

View file

@ -36,7 +36,6 @@ export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(function
autoCapitalize="off"
autoCorrect="off"
autoSave="off"
// autoFocus
placeholder=""
spellCheck="true"
wrap="off"

View file

@ -217,7 +217,7 @@ export class PointingShape extends StateNode {
if (this.editor.getInstanceState().isReadonly) return
// Re-focus the editor, just in case the text label of the shape has stolen focus
this.editor.getContainer().focus()
this.editor.focus()
this.parent.transition('translating', info)
}

View file

@ -150,7 +150,7 @@ export const EditLinkDialogInner = track(function EditLinkDialogInner({
ref={rInput}
className="tlui-edit-link-dialog__input"
label="edit-link-dialog.url"
autofocus
autoFocus
value={urlInputState.actual}
onValueChange={handleChange}
onComplete={handleComplete}

View file

@ -51,7 +51,7 @@ export const EmbedDialog = track(function EmbedDialog({ onClose }: TLUiDialogPro
className="tlui-embed-dialog__input"
label="embed-dialog.url"
placeholder="http://example.com"
autofocus
autoFocus
onValueChange={(value) => {
// Set the url that the user has typed into the input
setUrl(value)

View file

@ -34,8 +34,8 @@ export const PageItemInput = function PageItemInput({
onValueChange={handleChange}
onFocus={handleFocus}
shouldManuallyMaintainScrollPositionWhenFocused
autofocus={isCurrentPage}
autoselect
autoFocus={isCurrentPage}
autoSelect
/>
)
}

View file

@ -1,4 +1,3 @@
import { useEditor } from '@tldraw/editor'
import classnames from 'classnames'
import * as React from 'react'
@ -11,15 +10,6 @@ export interface TLUiButtonProps extends React.HTMLAttributes<HTMLButtonElement>
/** @public */
export const TldrawUiButton = React.forwardRef<HTMLButtonElement, TLUiButtonProps>(
function TldrawUiButton({ children, disabled, type, ...props }, ref) {
const editor = useEditor()
// If the button is getting disabled while it's focused, move focus to the editor
// so that the user can continue using keyboard shortcuts
const current = (ref as React.MutableRefObject<HTMLButtonElement | null>)?.current
if (disabled && current === document.activeElement) {
editor.getContainer().focus()
}
return (
<button
ref={ref}

View file

@ -40,6 +40,7 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
const msg = useTranslation()
const rPointing = useRef(false)
const rPointingOriginalActiveElement = useRef<HTMLElement | null>(null)
const {
handleButtonClick,
@ -50,6 +51,15 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
const handlePointerUp = () => {
rPointing.current = false
window.removeEventListener('pointerup', handlePointerUp)
// This is fun little micro-optimization to make sure that the focus
// is retained on a text label. That way, you can continue typing
// after selecting a style.
const origActiveEl = rPointingOriginalActiveElement.current
if (origActiveEl && ['TEXTAREA', 'INPUT'].includes(origActiveEl.nodeName)) {
origActiveEl.focus()
}
rPointingOriginalActiveElement.current = null
}
const handleButtonClick = (e: React.PointerEvent<HTMLButtonElement>) => {
@ -67,6 +77,7 @@ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>
onValueChange(style, id as T)
rPointing.current = true
rPointingOriginalActiveElement.current = document.activeElement as HTMLElement
window.addEventListener('pointerup', handlePointerUp) // see TLD-658
}

View file

@ -12,8 +12,8 @@ export interface TLUiInputProps {
label?: TLUiTranslationKey | Exclude<string, TLUiTranslationKey>
icon?: TLUiIconType | Exclude<string, TLUiIconType>
iconLeft?: TLUiIconType | Exclude<string, TLUiIconType>
autofocus?: boolean
autoselect?: boolean
autoFocus?: boolean
autoSelect?: boolean
children?: React.ReactNode
defaultValue?: string
placeholder?: string
@ -43,8 +43,8 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
label,
icon,
iconLeft,
autoselect = false,
autofocus = false,
autoSelect = false,
autoFocus = false,
defaultValue,
placeholder,
onComplete,
@ -75,13 +75,13 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
const elm = e.currentTarget as HTMLInputElement
rCurrentValue.current = elm.value
requestAnimationFrame(() => {
if (autoselect) {
if (autoSelect) {
elm.select()
}
})
onFocus?.()
},
[autoselect, onFocus]
[autoSelect, onFocus]
)
const handleChange = React.useCallback(
@ -159,7 +159,7 @@ export const TldrawUiInput = React.forwardRef<HTMLInputElement, TLUiInputProps>(
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autofocus}
autoFocus={autoFocus}
placeholder={placeholder}
value={value}
/>

View file

@ -26,8 +26,6 @@ export function useKeyboardShortcuts() {
useEffect(() => {
if (!isFocused) return
const container = editor.getContainer()
hotkeys.setScope(editor.store.id)
const hot = (keys: string, callback: (event: KeyboardEvent) => void) => {
@ -78,7 +76,7 @@ export function useKeyboardShortcuts() {
if (editor.inputs.keys.has('Comma')) return
preventDefault(e) // prevent whatever would normally happen
container.focus() // Focus if not already focused
editor.focus() // Focus if not already focused
editor.inputs.keys.add('Comma')

View file

@ -446,7 +446,9 @@ describe('isFocused', () => {
expect(editor.getInstanceState().isFocused).toBe(false)
})
it('becomes false when a child of the app container div receives a focusout event', () => {
it.skip('becomes false when a child of the app container div receives a focusout event', () => {
// This used to be true, but the focusout event doesn't actually bubble up anymore
// after we reworked to have the focus manager handle things.
const child = document.createElement('div')
editor.elm.appendChild(child)

View file

@ -20,10 +20,9 @@ function checkAllShapes(editor: Editor, shapes: string[]) {
describe('<TldrawEditor />', () => {
it('Renders without crashing', async () => {
await renderTldrawComponent(
<TldrawEditor tools={defaultTools} autoFocus initialState="select" />,
{ waitForPatterns: false }
)
await renderTldrawComponent(<TldrawEditor tools={defaultTools} initialState="select" />, {
waitForPatterns: false,
})
await screen.findByTestId('canvas')
})
@ -36,7 +35,6 @@ describe('<TldrawEditor />', () => {
}}
initialState="select"
tools={defaultTools}
autoFocus
/>,
{ waitForPatterns: false }
)
@ -53,7 +51,6 @@ describe('<TldrawEditor />', () => {
onMount={(e) => {
editor = e
}}
autoFocus
/>,
{ waitForPatterns: false }
)
@ -72,7 +69,6 @@ describe('<TldrawEditor />', () => {
onMount={(editor) => {
expect(editor.store).toBe(store)
}}
autoFocus
/>,
{ waitForPatterns: false }
)
@ -85,7 +81,6 @@ describe('<TldrawEditor />', () => {
// <TldrawEditor
// shapeUtils={[GroupShapeUtil]}
// store={createTLStore({ shapeUtils: [] })}
// autoFocus
// components={{
// ErrorFallback: ({ error }) => {
// throw error
@ -103,7 +98,6 @@ describe('<TldrawEditor />', () => {
// render(
// <TldrawEditor
// store={createTLStore({ shapeUtils: [GroupShapeUtil] })}
// autoFocus
// components={{
// ErrorFallback: ({ error }) => {
// throw error
@ -128,7 +122,6 @@ describe('<TldrawEditor />', () => {
tools={defaultTools}
store={initialStore}
onMount={onMount}
autoFocus
/>
)
const initialEditor = onMount.mock.lastCall[0]
@ -141,7 +134,6 @@ describe('<TldrawEditor />', () => {
initialState="select"
store={initialStore}
onMount={onMount}
autoFocus
/>
)
// not called again:
@ -149,13 +141,7 @@ describe('<TldrawEditor />', () => {
// re-render with a new store:
const newStore = createTLStore({ shapeUtils: [] })
rendered.rerender(
<TldrawEditor
tools={defaultTools}
initialState="select"
store={newStore}
onMount={onMount}
autoFocus
/>
<TldrawEditor tools={defaultTools} initialState="select" store={newStore} onMount={onMount} />
)
expect(initialEditor.dispose).toHaveBeenCalledTimes(1)
expect(onMount).toHaveBeenCalledTimes(2)
@ -169,7 +155,6 @@ describe('<TldrawEditor />', () => {
shapeUtils={[GeoShapeUtil]}
initialState="select"
tools={defaultTools}
autoFocus
onMount={(editorApp) => {
editor = editorApp
}}
@ -285,7 +270,6 @@ describe('Custom shapes', () => {
<TldrawEditor
shapeUtils={shapeUtils}
tools={[...defaultTools, ...tools]}
autoFocus
initialState="select"
onMount={(editorApp) => {
editor = editorApp