2023-07-19 10:52:21 +00:00
|
|
|
import test, { expect } from '@playwright/test'
|
2024-02-29 16:06:19 +00:00
|
|
|
import { Editor } from 'tldraw'
|
2023-07-19 10:52:21 +00:00
|
|
|
|
2023-10-04 09:01:48 +00:00
|
|
|
declare const EDITOR_A: Editor
|
|
|
|
declare const EDITOR_B: Editor
|
|
|
|
declare const EDITOR_C: Editor
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
// We're just testing the events, not the actual results.
|
|
|
|
|
|
|
|
test.describe('Focus', () => {
|
|
|
|
test('focus events', async ({ page }) => {
|
2024-02-02 17:36:30 +00:00
|
|
|
await page.goto('http://localhost:5420/multiple/full')
|
2023-07-19 10:52:21 +00:00
|
|
|
await page.waitForSelector('.tl-canvas')
|
|
|
|
|
|
|
|
const EditorA = (await page.$(`.A`))!
|
|
|
|
const EditorB = (await page.$(`.B`))!
|
2023-10-04 09:01:48 +00:00
|
|
|
const EditorC = (await page.$(`.C`))!
|
2023-07-19 10:52:21 +00:00
|
|
|
expect(EditorA).toBeTruthy()
|
|
|
|
expect(EditorB).toBeTruthy()
|
2023-10-04 09:01:48 +00:00
|
|
|
expect(EditorC).toBeTruthy()
|
|
|
|
|
|
|
|
async function isOnlyFocused(id: 'A' | 'B' | 'C' | null) {
|
|
|
|
let activeElement: string | null = null
|
|
|
|
const isA = await EditorA.evaluate(
|
|
|
|
(node) => document.activeElement === node || node.contains(document.activeElement)
|
|
|
|
)
|
|
|
|
const isB = await EditorB.evaluate(
|
|
|
|
(node) => document.activeElement === node || node.contains(document.activeElement)
|
|
|
|
)
|
|
|
|
|
|
|
|
const isC = await EditorC.evaluate(
|
|
|
|
(node) => document.activeElement === node || node.contains(document.activeElement)
|
|
|
|
)
|
|
|
|
|
|
|
|
activeElement = isA ? 'A' : isB ? 'B' : isC ? 'C' : null
|
|
|
|
|
|
|
|
expect(
|
|
|
|
activeElement,
|
|
|
|
`Active element should have been ${id}, but was ${activeElement ?? 'null'} instead`
|
|
|
|
).toBe(id)
|
|
|
|
|
|
|
|
await page.evaluate(
|
|
|
|
({ id }) => {
|
|
|
|
if (
|
|
|
|
!(
|
2023-11-13 11:51:22 +00:00
|
|
|
EDITOR_A.getInstanceState().isFocused === (id === 'A') &&
|
|
|
|
EDITOR_B.getInstanceState().isFocused === (id === 'B') &&
|
|
|
|
EDITOR_C.getInstanceState().isFocused === (id === 'C')
|
2023-10-04 09:01:48 +00:00
|
|
|
)
|
|
|
|
) {
|
|
|
|
throw Error('isFocused is not correct')
|
|
|
|
}
|
|
|
|
},
|
|
|
|
{ id }
|
|
|
|
)
|
|
|
|
}
|
2023-07-19 10:52:21 +00:00
|
|
|
|
2023-10-04 09:01:48 +00:00
|
|
|
// Component A has autofocus
|
|
|
|
// Component B does not
|
|
|
|
// Component C does not
|
|
|
|
// Component B and C share persistence id
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await (await page.$('body'))?.click()
|
|
|
|
|
2023-10-04 09:01:48 +00:00
|
|
|
await isOnlyFocused('A')
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await EditorA.click()
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
await isOnlyFocused('A')
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await EditorA.click()
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
await isOnlyFocused('A')
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await EditorB.click()
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
await isOnlyFocused('B')
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
// Escape does not break focus
|
|
|
|
await page.keyboard.press('Escape')
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
await isOnlyFocused('B')
|
|
|
|
})
|
|
|
|
|
|
|
|
test('kbds when not focused', async ({ page }) => {
|
2024-02-02 17:36:30 +00:00
|
|
|
await page.goto('http://localhost:5420/multiple/full')
|
2023-10-04 09:01:48 +00:00
|
|
|
await page.waitForSelector('.tl-canvas')
|
|
|
|
|
|
|
|
// Should not have any shapes on the page
|
2023-11-14 16:32:27 +00:00
|
|
|
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(0)
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
const EditorA = (await page.$(`.A`))!
|
|
|
|
await page.keyboard.press('r')
|
|
|
|
await EditorA.click({ position: { x: 100, y: 100 } })
|
|
|
|
|
|
|
|
// Should not have created a shape
|
2023-11-14 16:32:27 +00:00
|
|
|
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
|
2023-10-04 09:01:48 +00:00
|
|
|
|
|
|
|
const TextArea = page.getByTestId(`textarea`)
|
|
|
|
await TextArea.focus()
|
|
|
|
await page.keyboard.type('hello world')
|
|
|
|
await page.keyboard.press('Control+A')
|
|
|
|
await page.keyboard.press('Delete')
|
|
|
|
|
|
|
|
// Should not have deleted the page
|
2023-11-14 16:32:27 +00:00
|
|
|
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
|
2023-07-19 10:52:21 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
test('kbds when focused', async ({ page }) => {
|
2024-02-02 17:36:30 +00:00
|
|
|
await page.goto('http://localhost:5420/multiple/full')
|
2023-07-19 10:52:21 +00:00
|
|
|
await page.waitForSelector('.tl-canvas')
|
|
|
|
|
|
|
|
const EditorA = (await page.$(`.A`))!
|
|
|
|
const EditorB = (await page.$(`.B`))!
|
2023-10-04 09:01:48 +00:00
|
|
|
const EditorC = (await page.$(`.C`))!
|
2023-07-19 10:52:21 +00:00
|
|
|
expect(EditorA).toBeTruthy()
|
|
|
|
expect(EditorB).toBeTruthy()
|
2023-10-04 09:01:48 +00:00
|
|
|
expect(EditorC).toBeTruthy()
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await (await page.$('body'))?.click()
|
|
|
|
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).toBe(
|
2023-07-19 10:52:21 +00:00
|
|
|
null
|
|
|
|
)
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).toBe(
|
2023-07-19 10:52:21 +00:00
|
|
|
null
|
|
|
|
)
|
|
|
|
|
|
|
|
await page.keyboard.press('d')
|
|
|
|
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).not.toBe(
|
|
|
|
null
|
|
|
|
)
|
|
|
|
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).toBe(
|
2023-07-19 10:52:21 +00:00
|
|
|
null
|
|
|
|
)
|
|
|
|
|
|
|
|
await EditorB.click()
|
|
|
|
await page.waitForTimeout(100) // takes 30ms or so to focus
|
|
|
|
await page.keyboard.press('d')
|
|
|
|
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).not.toBe(
|
|
|
|
null
|
|
|
|
)
|
|
|
|
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).not.toBe(
|
|
|
|
null
|
|
|
|
)
|
2023-07-19 10:52:21 +00:00
|
|
|
})
|
|
|
|
|
|
|
|
test('kbds after clicking on ui elements', 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()
|
|
|
|
|
|
|
|
const drawButton = await EditorA.$('.tlui-button[data-testid="tools.draw"]')
|
|
|
|
|
|
|
|
// select button should be selected, not the draw button
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).toBe(
|
2023-07-19 10:52:21 +00:00
|
|
|
null
|
|
|
|
)
|
|
|
|
|
|
|
|
await drawButton?.click()
|
|
|
|
|
|
|
|
// draw button should be selected now
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).not.toBe(
|
|
|
|
null
|
|
|
|
)
|
2023-07-19 10:52:21 +00:00
|
|
|
|
|
|
|
await page.keyboard.press('v')
|
|
|
|
|
|
|
|
// select button should be selected again
|
2024-03-12 16:14:28 +00:00
|
|
|
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-checked="true"]')).toBe(
|
2023-07-19 10:52:21 +00:00
|
|
|
null
|
|
|
|
)
|
|
|
|
})
|
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
2024-05-17 08:53:57 +00:00
|
|
|
|
|
|
|
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)
|
|
|
|
})
|
|
|
|
|
2024-05-30 13:06:40 +00:00
|
|
|
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()
|
|
|
|
|
|
|
|
await page.waitForTimeout(1000)
|
|
|
|
|
|
|
|
// First note's textarea should be focused.
|
|
|
|
expect(await EditorA.evaluate(() => !!document.querySelector('.tl-shape textarea:focus'))).toBe(
|
|
|
|
true
|
|
|
|
)
|
|
|
|
})
|
2023-07-19 10:52:21 +00:00
|
|
|
})
|