Remove focus management (#1953)

This PR removes the automatic focus events from the editor.

The `autoFocus` prop is now true by default. When true, the editor will
begin in a focused state (`editor.instanceState.isFocused` will be
`true`) and the component will respond to keyboard shortcuts and other
interactions. When false, the editor will begin in an unfocused state
and not respond to keyboard interactions.

**It's now up to the developer** using the component to update
`isFocused` themselves. There's no predictable way to do that on our
side, so we leave it to the developer to decide when to turn on or off
focus for a container (for example, using an intersection observer to
"unfocus" components that are off screen).

### Change Type

- [x] `major` — Breaking change

### Test Plan

1. Open the multiple editors example.
2. Click to focus each editor.
3. Use the keyboard shortcuts to check that the correct editor is
focused.
4. Start editing a shape, then select the other editor. The first
editing shape should complete.

- [x] Unit Tests
- [x] End to end tests

### Release Notes

- [editor] Make autofocus default, remove automatic blur / focus events.

---------

Co-authored-by: David Sheldrick <d.j.sheldrick@gmail.com>
This commit is contained in:
Steve Ruiz 2023-10-02 12:29:54 +01:00 committed by GitHub
parent 3fa7dd359d
commit da33179a31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 196 additions and 195 deletions

View file

@ -106,7 +106,7 @@ test.describe('Canvas events', () => {
})
test.describe('Shape events', () => {
test.beforeAll(async ({ browser }) => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
await page.keyboard.press('r')
@ -115,36 +115,34 @@ test.describe('Shape events', () => {
await page.keyboard.press('Escape')
})
test.describe('pointer events', () => {
test('pointer down', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_down',
})
test('pointer down', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_down',
})
})
test('pointer move', async () => {
await page.mouse.move(51, 51)
await page.mouse.move(52, 52)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_move',
})
test('pointer move', async () => {
await page.mouse.move(51, 51)
await page.mouse.move(52, 52)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_move',
})
})
test('pointer up', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_up',
})
test('pointer up', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_up',
})
})
})

View file

@ -38,12 +38,13 @@ test.describe('Focus', () => {
await EditorB.click()
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(true)
expect(await EditorB.evaluate((node) => document.activeElement === node)).toBe(true)
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(false)
// Escape does not break focus
await page.keyboard.press('Escape')
expect(await EditorA.evaluate((node) => node.contains(document.activeElement))).toBe(true)
expect(await EditorA.evaluate((node) => document.activeElement === node)).toBe(false)
expect(await EditorB.evaluate((node) => node.contains(document.activeElement))).toBe(true)
})
test('kbds when focused', async ({ page }) => {

View file

@ -4,7 +4,7 @@ import '@tldraw/tldraw/tldraw.css'
export default function BasicExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="tldraw_example" autoFocus />
<Tldraw persistenceKey="tldraw_example" />
</div>
)
}

View file

@ -5,7 +5,6 @@ export default function AssetPropsExample() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
// only allow jpegs
acceptedImageMimeTypes={['image/jpeg']}
// don't allow any images

View file

@ -13,7 +13,6 @@ export default function CanvasEventsExample() {
<div style={{ display: 'flex' }}>
<div style={{ width: '50vw', height: '100vh' }}>
<Tldraw
autoFocus
onMount={(editor) => {
editor.on('event', (event) => handleEvent(event))
}}

View file

@ -11,7 +11,6 @@ export default function CustomConfigExample() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
// Pass in the array of custom shape classes
shapeUtils={customShapeUtils}
// Pass in the array of custom tool classes

View file

@ -11,7 +11,6 @@ export default function CustomStylesExample() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
persistenceKey="custom-styles-example"
shapeUtils={customShapeUtils}
tools={customTools}

View file

@ -6,7 +6,7 @@ import './custom-ui.css'
export default function CustomUiExample() {
return (
<div className="tldraw__editor">
<Tldraw hideUi autoFocus>
<Tldraw hideUi>
<Canvas />
<CustomUi />
</Tldraw>

View file

@ -15,7 +15,6 @@ export default function ExplodedExample() {
initialState="select"
shapeUtils={defaultShapeUtils}
tools={defaultTools}
autoFocus
persistenceKey="exploded-example"
>
<TldrawUi>

View file

@ -4,7 +4,7 @@ import '@tldraw/tldraw/tldraw.css'
export default function HideUiExample() {
return (
<div className="tldraw__editor">
<Tldraw persistenceKey="hide-ui-example" autoFocus hideUi />
<Tldraw persistenceKey="hide-ui-example" hideUi />
</div>
)
}

View file

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

View file

@ -1,7 +1,26 @@
import { Tldraw } from '@tldraw/tldraw'
import '@tldraw/tldraw/tldraw.css'
import { createContext, useCallback, useContext, useState } from 'react'
const focusedEditorContext = createContext(
{} as {
focusedEditor: string
setFocusedEditor: (id: string) => void
}
)
export default function MultipleExample() {
const [focusedEditor, focusedEditorSetter] = useState('first')
const setFocusedEditor = useCallback(
(id: string) => {
if (focusedEditor !== id) {
focusedEditorSetter(id)
}
},
[focusedEditor]
)
return (
<div
style={{
@ -9,64 +28,95 @@ export default function MultipleExample() {
padding: 32,
}}
>
<focusedEditorContext.Provider value={{ focusedEditor, setFocusedEditor }}>
<h1>Focusing: "{focusedEditor}"</h1>
<FirstEditor />
<textarea defaultValue="type in me" style={{ margin: 10 }}></textarea>
<SecondEditor />
<ABunchOfText />
</focusedEditorContext.Provider>
</div>
)
}
function FirstEditor() {
const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext)
return (
<div>
<h2>First Example</h2>
<p>This is the second example.</p>
<div style={{ width: '100%', height: '600px', padding: 32 }} tabIndex={-1}>
<Tldraw persistenceKey="steve" className="A" autoFocus />
</div>
<textarea defaultValue="type in me" style={{ margin: 10 }}></textarea>
<h2>Second Example</h2>
<p>This is the second example.</p>
<div style={{ width: '100%', height: '600px' }} tabIndex={-1}>
<Tldraw persistenceKey="david" className="B" />
</div>
<div
style={{ width: '100%', display: 'flex', alignItems: 'center', flexDirection: 'column' }}
tabIndex={-1}
onFocus={() => setFocusedEditor('first')}
style={{ width: '100%', height: '600px', padding: 32 }}
>
<article style={{ maxWidth: 600 }}>
<h1>White Board</h1>
<h2>Chapter 1: The First Strokes</h2>
<p>
The fluorescent lights flickered overhead as John sat hunched over his desk, his fingers
tapping rhythmically on the keyboard. He was a software developer, and tonight, he had a
peculiar mission. A mission that would take him deep into the labyrinthine world of web
development. John had stumbled upon a new whiteboard library called "tldraw," a
seemingly simple tool that promised to revolutionize collaborative drawing on the web.
Little did he know that this discovery would set off a chain of events that would
challenge his skills, test his perseverance, and blur the line between reality and
imagination.
</p>
<p>
With a newfound sense of excitement, John began integrating "tldraw" into his latest
project. As lines of code danced across his screen, he imagined the possibilities that
lay ahead. The potential to create virtual spaces where ideas could be shared, concepts
could be visualized, and teams could collaborate seamlessly from different corners of
the world. It was a dream that seemed within reach, a vision of a future where
creativity and technology merged into a harmonious symphony.
</p>
<p>
As the night wore on, John's mind became consumed with the whiteboard library. He
couldn't help but marvel at its elegance and simplicity. With each stroke of his
keyboard, he felt a surge of inspiration, a connection to something greater than
himself. It was as if the lines of code he was writing were transforming into a digital
canvas, waiting to be filled with the strokes of imagination. In that moment, John
realized that he was not just building a tool, but breathing life into a new form of
expression. The whiteboard was no longer just a blank slate; it had become a portal to a
world where ideas could flourish and dreams could take shape.
</p>
<p>
Little did John know, this integration of "tldraw" was only the beginning. It would lead
him down a path filled with unforeseen challenges, where he would confront his own
limitations and question the very nature of creation. The journey ahead would test his
resolve, pushing him to the edge of his sanity. And as he embarked on this perilous
adventure, he could not shake the feeling that the whiteboard held secrets far beyond
his understanding. Secrets that would unfold before his eyes, one stroke at a time.
</p>
</article>
<Tldraw persistenceKey="steve" className="A" autoFocus={focusedEditor === 'first'} />
</div>
</div>
)
}
function SecondEditor() {
const { focusedEditor, setFocusedEditor } = useContext(focusedEditorContext)
return (
<div>
<h2>Second Example</h2>
<p>This is the second example.</p>
<div
tabIndex={-1}
onFocus={() => setFocusedEditor('second')}
style={{ width: '100%', height: '600px' }}
>
<Tldraw persistenceKey="david" className="B" autoFocus={focusedEditor === 'second'} />
</div>
</div>
)
}
function ABunchOfText() {
return (
<div style={{ width: '100%', display: 'flex', alignItems: 'center', flexDirection: 'column' }}>
<article style={{ maxWidth: 600 }}>
<h1>White Board</h1>
<h2>Chapter 1: The First Strokes</h2>
<p>
The fluorescent lights flickered overhead as John sat hunched over his desk, his fingers
tapping rhythmically on the keyboard. He was a software developer, and tonight, he had a
peculiar mission. A mission that would take him deep into the labyrinthine world of web
development. John had stumbled upon a new whiteboard library called "tldraw," a seemingly
simple tool that promised to revolutionize collaborative drawing on the web. Little did he
know that this discovery would set off a chain of events that would challenge his skills,
test his perseverance, and blur the line between reality and imagination.
</p>
<p>
With a newfound sense of excitement, John began integrating "tldraw" into his latest
project. As lines of code danced across his screen, he imagined the possibilities that lay
ahead. The potential to create virtual spaces where ideas could be shared, concepts could
be visualized, and teams could collaborate seamlessly from different corners of the world.
It was a dream that seemed within reach, a vision of a future where creativity and
technology merged into a harmonious symphony.
</p>
<p>
As the night wore on, John's mind became consumed with the whiteboard library. He couldn't
help but marvel at its elegance and simplicity. With each stroke of his keyboard, he felt
a surge of inspiration, a connection to something greater than himself. It was as if the
lines of code he was writing were transforming into a digital canvas, waiting to be filled
with the strokes of imagination. In that moment, John realized that he was not just
building a tool, but breathing life into a new form of expression. The whiteboard was no
longer just a blank slate; it had become a portal to a world where ideas could flourish
and dreams could take shape.
</p>
<p>
Little did John know, this integration of "tldraw" was only the beginning. It would lead
him down a path filled with unforeseen challenges, where he would confront his own
limitations and question the very nature of creation. The journey ahead would test his
resolve, pushing him to the edge of his sanity. And as he embarked on this perilous
adventure, he could not shake the feeling that the whiteboard held secrets far beyond his
understanding. Secrets that would unfold before his eyes, one stroke at a time.
</p>
</article>
</div>
)
}

View file

@ -62,7 +62,7 @@ export default function PersistenceExample() {
return (
<div className="tldraw__editor">
<Tldraw store={store} autoFocus />
<Tldraw store={store} />
</div>
)
}

View file

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

View file

@ -6,7 +6,6 @@ export default function ShapeMetaExample() {
<div className="tldraw__editor">
<Tldraw
persistenceKey="shape_meta_example"
autoFocus
onMount={(editor) => {
editor.getInitialMetaForShape = (shape) => {
return { label: `My ${shape.type} shape` }

View file

@ -26,7 +26,6 @@ export default function SnapshotExample() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
onMount={(editor) => {
editor.store.loadSnapshot(jsonSnapshot)
}}

View file

@ -58,7 +58,7 @@ export default function StoreEventsExample() {
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '60vw', height: '100vh' }}>
<Tldraw autoFocus onMount={setAppToState} />
<Tldraw onMount={setAppToState} />
</div>
<div>
<div

View file

@ -12,7 +12,7 @@ export default function UiEventsExample() {
return (
<div style={{ display: 'flex' }}>
<div style={{ width: '60vw', height: '100vh' }}>
<Tldraw autoFocus onUiEvent={handleUiEvent} />
<Tldraw onUiEvent={handleUiEvent} />
</div>
<div>
<div

View file

@ -7,7 +7,6 @@ export default function EndToEnd() {
return (
<div className="tldraw__editor">
<Tldraw
autoFocus
onMount={(editor) => {
editor.on('event', (info) => {
;(window as any).__tldraw_editor_events.push(info)

View file

@ -12,7 +12,6 @@ export default function OnlyEditorExample() {
return (
<div className="tldraw__editor">
<TldrawEditor
autoFocus
tools={myTools}
shapeUtils={myShapeUtils}
initialState="select"

View file

@ -255,9 +255,9 @@ function TldrawEditorWithReadyStore({
store,
tools,
shapeUtils,
autoFocus,
user,
initialState,
autoFocus = true,
inferDarkMode,
}: Required<
TldrawEditorProps & {
@ -338,11 +338,11 @@ function TldrawEditorWithReadyStore({
function Layout({
children,
onMount,
autoFocus = false,
autoFocus,
}: {
children: any
autoFocus: boolean
onMount?: TLOnMountHandler
autoFocus?: boolean
}) {
useZoomCss()
useCursor()
@ -353,6 +353,9 @@ function Layout({
useOnMount(onMount)
useDPRMultiple()
const editor = useEditor()
editor.updateViewportScreenBounds()
return children ?? <Canvas />
}

View file

@ -1,56 +1,15 @@
import { debounce } from '@tldraw/utils'
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 (!container) return
// We need to debounce this because when focus changes, the body
// becomes focused for a brief moment. Debouncing means that we
// check only when focus stops changing: when it settles, what
// has it settled on? If it's settled on the container or something
// inside of the container, then focus or preserve the current focus;
// if not, then turn off focus. Turning off focus is a trigger to
// also turn off keyboard shortcuts and other things.
const updateFocus = debounce(() => {
const { activeElement } = document
const { isFocused: wasFocused } = editor.instanceState
const isFocused =
document.hasFocus() && (container === activeElement || container.contains(activeElement))
if (wasFocused !== isFocused) {
editor.updateInstanceState({ isFocused })
editor.updateViewportScreenBounds()
if (!isFocused) {
// When losing focus, run complete() to ensure that any interacts end
editor.complete()
}
}
}, 32)
container.addEventListener('focusin', updateFocus)
container.addEventListener('focus', updateFocus)
container.addEventListener('focusout', updateFocus)
container.addEventListener('blur', updateFocus)
return () => {
container.removeEventListener('focusin', updateFocus)
container.removeEventListener('focus', updateFocus)
container.removeEventListener('focusout', updateFocus)
container.removeEventListener('blur', updateFocus)
}
}, [container, editor])
useLayoutEffect(() => {
if (autoFocus) {
if (autoFocus && !editor.instanceState.isFocused) {
editor.updateInstanceState({ isFocused: true })
editor.getContainer().focus()
} else if (editor.instanceState.isFocused) {
editor.updateInstanceState({ isFocused: false })
}
}, [editor, autoFocus])
}

View file

@ -18,9 +18,10 @@ export function useScreenBounds() {
}
)
editor.updateViewportScreenBounds()
// Rather than running getClientRects on every frame, we'll
// run it once a second or when the window resizes / scrolls.
updateBounds()
const interval = setInterval(updateBounds, 1000)
window.addEventListener('resize', updateBounds)
window.addEventListener('scroll', updateBounds)
@ -30,5 +31,5 @@ export function useScreenBounds() {
window.removeEventListener('resize', updateBounds)
window.removeEventListener('scroll', updateBounds)
}
})
}, [editor])
}

View file

@ -20,7 +20,7 @@ afterEach(() => {
describe('<Tldraw />', () => {
it('Renders without crashing', async () => {
await act(async () => (
<Tldraw autoFocus>
<Tldraw>
<div data-testid="canvas-1" />
</Tldraw>
))

View file

@ -139,7 +139,7 @@ function InsideOfEditorContext({
const onMountEvent = useEvent((editor: Editor) => {
const unsubs: (void | (() => void) | undefined)[] = []
unsubs.push(registerDefaultSideEffects(editor))
unsubs.push(...registerDefaultSideEffects(editor))
// for content handling, first we register the default handlers...
registerDefaultExternalContentHandlers(editor, {

View file

@ -1,34 +1,49 @@
import { Editor } from '@tldraw/editor'
export function registerDefaultSideEffects(editor: Editor) {
return editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny(
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle')
}
} else if (prev.croppingShapeId && !next.croppingShapeId) {
if (isInCroppingState) {
editor.setCurrentTool('select.idle')
return [
editor.sideEffects.registerAfterChangeHandler('instance', (prev, next) => {
if (prev.isFocused !== next.isFocused) {
if (next.isFocused) {
editor.complete() // stop any interaction
editor.getContainer().focus()
editor.updateViewportScreenBounds()
} else {
editor.complete() // stop any interaction
editor.getContainer().blur() // blur the container
editor.updateViewportScreenBounds()
}
}
}),
editor.sideEffects.registerAfterChangeHandler('instance_page_state', (prev, next) => {
if (prev.croppingShapeId !== next.croppingShapeId) {
const isInCroppingState = editor.isInAny(
'select.crop',
'select.pointing_crop_handle',
'select.cropping'
)
if (!prev.croppingShapeId && next.croppingShapeId) {
if (!isInCroppingState) {
editor.setCurrentTool('select.crop.idle')
}
} else if (prev.croppingShapeId && !next.croppingShapeId) {
if (isInCroppingState) {
editor.setCurrentTool('select.idle')
}
}
}
}
if (prev.editingShapeId !== next.editingShapeId) {
if (!prev.editingShapeId && next.editingShapeId) {
if (!editor.isIn('select.editing_shape')) {
editor.setCurrentTool('select.editing_shape')
}
} else if (prev.editingShapeId && !next.editingShapeId) {
if (editor.isIn('select.editing_shape')) {
editor.setCurrentTool('select.idle')
if (prev.editingShapeId !== next.editingShapeId) {
if (!prev.editingShapeId && next.editingShapeId) {
if (!editor.isIn('select.editing_shape')) {
editor.setCurrentTool('select.editing_shape')
}
} else if (prev.editingShapeId && !next.editingShapeId) {
if (editor.isIn('select.editing_shape')) {
editor.setCurrentTool('select.idle')
}
}
}
}
})
}),
]
}

View file

@ -110,7 +110,7 @@ export const Toolbar = memo(function Toolbar() {
<div className="tlui-toolbar">
<div className="tlui-toolbar__inner">
<div className="tlui-toolbar__left">
{!isReadonly && (
{!isReadonly && breakpoint < 6 && !editor.isInAny('hand', 'zoom') && (
<div
className={classNames('tlui-toolbar__extras', {
'tlui-toolbar__extras__hidden': !showExtraActions,

View file

@ -460,23 +460,6 @@ describe('isFocused', () => {
jest.advanceTimersByTime(100)
expect(editor.instanceState.isFocused).toBe(false)
})
it('calls .focus() and .blur() on the container div when you call .focus() and .blur() on the editor', () => {
const focusMock = jest.spyOn(editor.elm, 'focus').mockImplementation()
const blurMock = jest.spyOn(editor.elm, 'blur').mockImplementation()
expect(focusMock).not.toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.getContainer().focus()
expect(focusMock).toHaveBeenCalled()
expect(blurMock).not.toHaveBeenCalled()
editor.getContainer().blur()
expect(blurMock).toHaveBeenCalled()
})
})
describe('getShapeUtil', () => {