snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
# Performance Testing
FPS performance tests for tldraw to detect regressions and track improvements.
These are tracked in https://eu.posthog.com/project/45972/insights/ZdpGyj1r
```bash
# Run all performance tests
yarn e2e-perf
# With UI
yarn e2e-perf-ui
```
Baselines are automatically created on first run.
## Regression Detection
- **Fail**: >10% performance drop
- **Warning**: 5-10% change
- **Pass**: Performance stable
Results compared against baselines in `baselines/fps-baselines.json`.
## Configuration
### Environment Variables
When `PERFORMANCE_ANALYTICS_ENABLED` enabled, sends performance metrics and regression alerts to PostHog.
```bash
# Analytics (optional)
PERFORMANCE_ANALYTICS_ENABLED=true
POSTHOG_PROJECT_KEY=your-key
# CI context
GIT_COMMIT=abc123
GIT_BRANCH=main
```

View File

@@ -0,0 +1,90 @@
import assert from 'assert'
import { rename, writeFile } from 'fs/promises'
import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
import test, { ApiFixture } from '../fixtures/fixtures'
import { hardResetEditor, setup } from '../shared-e2e'
declare const editor: Editor
interface MermaidSnapshotCase {
name: string
definition: string
}
const snapshotCases: MermaidSnapshotCase[] = [
{ name: 'flowchart decision', definition: mermaidDefinitions[0][0] },
{ name: 'flowchart with subgraphs', definition: mermaidDefinitions[0][10] },
{ name: 'flowchart with class defs', definition: mermaidDefinitions[0][15] },
{ name: 'flowchart with custom edge styles', definition: mermaidDefinitions[0][17] },
{ name: 'state diagram basic', definition: mermaidDefinitions[1][1] },
{ name: 'state diagram with nested state', definition: mermaidDefinitions[1][8] },
{ name: 'state diagram with fork join', definition: mermaidDefinitions[1][11] },
{ name: 'sequence diagram basics', definition: mermaidDefinitions[2][0] },
{ name: 'sequence diagram with alt', definition: mermaidDefinitions[2][10] },
{ name: 'sequence diagram with parallel lanes', definition: mermaidDefinitions[2][12] },
]
test.describe('Mermaid export snapshots', () => {
test.skip(({ isMobile }) => isMobile, 'Mermaid export snapshots are desktop-only')
test.beforeEach(async ({ page, context }) => {
const url = page.url()
if (!url.includes('end-to-end')) {
await setup({ page, context } as any)
} else {
await hardResetEditor(page)
}
})
for (const testCase of snapshotCases) {
test(`${testCase.name} (light)`, async ({ page, api }) => {
await page.evaluate(() => {
editor.user.updateUserPreferences({ colorScheme: 'light' })
editor
.updateInstanceState({ exportBackground: true })
.selectAll()
.deleteShapes(editor.getSelectedShapeIds())
})
await api.resetMockShapeIds()
await api.createMermaidDiagram(testCase.definition)
await page.evaluate(() => {
editor.selectAll()
})
await snapshotTest(page, api)
})
}
})
async function snapshotTest(page: Page, api: ApiFixture) {
const downloadAndSnapshot = page.waitForEvent('download').then(async (download) => {
const path = (await download.path()) as string
assert(path)
await rename(path, path + '.svg')
await writeFile(
path + '.html',
`
<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<img src="${path}.svg" />
`,
'utf-8'
)
await page.goto(`file://${path}.html`)
const clip = await page.$eval('img', (img) => img.getBoundingClientRect())
await expect(page).toHaveScreenshot({
omitBackground: true,
clip,
fullPage: true,
// Rasterizing the exported SVG moves box outlines by a pixel now and then, about 0.2% of the
// image (#10758). Mermaid layout is also checked by position in `e2e/mermaid-readability`.
maxDiffPixelRatio: 0.003,
})
})
await api.exportAsSvg()
await downloadAndSnapshot
}

View File

@@ -0,0 +1,582 @@
/* eslint-disable react/no-string-refs, tldraw/no-internal-imports */
import { b64Vecs, degreesToRadians, TLShapeCrop, toRichText, VecModel } from 'tldraw'
import { TL } from 'tldraw/src/test/test-jsx'
const convexPoints: VecModel[] = [
{ x: 0, y: 0, z: 0.5 },
{ x: 0.1, y: 0, z: 0.5 },
{ x: 0.47, y: 0, z: 0.5 },
{ x: 1.61, y: 0, z: 0.5 },
{ x: 3.9, y: 0, z: 0.5 },
{ x: 7.09, y: 0, z: 0.5 },
{ x: 10.66, y: 0, z: 0.5 },
{ x: 14.15, y: 0, z: 0.5 },
{ x: 17.3, y: 0, z: 0.5 },
{ x: 20.28, y: 0, z: 0.5 },
{ x: 23.43, y: 0.01, z: 0.5 },
{ x: 26.21, y: 0.33, z: 0.5 },
{ x: 28.78, y: 1.22, z: 0.5 },
{ x: 31.52, y: 2.42, z: 0.5 },
{ x: 34.2, y: 3.92, z: 0.5 },
{ x: 36.52, y: 5.72, z: 0.5 },
{ x: 38.36, y: 7.62, z: 0.5 },
{ x: 40.26, y: 10.03, z: 0.5 },
{ x: 42.18, y: 12.96, z: 0.5 },
{ x: 44.04, y: 16.13, z: 0.5 },
{ x: 45.89, y: 19.87, z: 0.5 },
{ x: 47.5, y: 24.12, z: 0.5 },
{ x: 48.77, y: 28.45, z: 0.5 },
{ x: 49.75, y: 32.78, z: 0.5 },
{ x: 50.36, y: 36.93, z: 0.5 },
{ x: 50.72, y: 41.07, z: 0.5 },
{ x: 50.84, y: 45.22, z: 0.5 },
{ x: 50.84, y: 48.58, z: 0.5 },
{ x: 50.84, y: 52.02, z: 0.5 },
{ x: 50.79, y: 55.21, z: 0.5 },
{ x: 50.45, y: 57.41, z: 0.5 },
{ x: 49.83, y: 59.48, z: 0.5 },
{ x: 49.09, y: 61.37, z: 0.5 },
{ x: 48.22, y: 63.04, z: 0.5 },
{ x: 47.28, y: 64.65, z: 0.5 },
{ x: 46.22, y: 66.3, z: 0.5 },
{ x: 45.03, y: 68.03, z: 0.5 },
{ x: 43.81, y: 69.72, z: 0.5 },
{ x: 42.48, y: 71.36, z: 0.5 },
{ x: 40.85, y: 73.1, z: 0.5 },
{ x: 39.24, y: 74.55, z: 0.5 },
{ x: 37.4, y: 75.93, z: 0.5 },
{ x: 35.5, y: 77.19, z: 0.5 },
{ x: 33.64, y: 78.12, z: 0.5 },
{ x: 31.66, y: 79.04, z: 0.5 },
{ x: 29.82, y: 79.77, z: 0.5 },
{ x: 27.93, y: 80.3, z: 0.5 },
{ x: 25.85, y: 80.75, z: 0.5 },
{ x: 23.56, y: 81.14, z: 0.5 },
{ x: 21.35, y: 81.52, z: 0.5 },
{ x: 19.18, y: 81.81, z: 0.5 },
{ x: 17.08, y: 81.95, z: 0.5 },
{ x: 15.18, y: 81.99, z: 0.5 },
{ x: 13.43, y: 81.99, z: 0.5 },
{ x: 11.91, y: 81.99, z: 0.5 },
{ x: 10.63, y: 81.99, z: 0.5 },
{ x: 9.47, y: 81.99, z: 0.5 },
{ x: 8.43, y: 81.99, z: 0.5 },
{ x: 7.57, y: 81.99, z: 0.5 },
{ x: 6.81, y: 81.99, z: 0.5 },
{ x: 6.05, y: 81.99, z: 0.5 },
{ x: 5.31, y: 81.99, z: 0.5 },
{ x: 4.67, y: 81.99, z: 0.5 },
{ x: 4.17, y: 81.99, z: 0.5 },
{ x: 3.71, y: 81.99, z: 0.5 },
{ x: 3.26, y: 81.99, z: 0.5 },
{ x: 2.85, y: 81.99, z: 0.5 },
{ x: 2.46, y: 81.99, z: 0.5 },
{ x: 2.13, y: 81.99, z: 0.5 },
{ x: 1.77, y: 81.99, z: 0.5 },
{ x: 1.37, y: 81.99, z: 0.5 },
{ x: 0.96, y: 81.99, z: 0.5 },
{ x: 0.46, y: 81.99, z: 0.5 },
{ x: -0.03, y: 81.99, z: 0.5 },
{ x: -0.37, y: 81.99, z: 0.5 },
{ x: -0.62, y: 82.01, z: 0.5 },
]
export const frameContent = (
<>
<TL.geo
richText={toRichText('content')}
w={100}
h={100}
x={50}
y={50}
rotation={degreesToRadians(35)}
fill="solid"
color="orange"
/>
<TL.arrow start={{ x: 50, y: 50 }} end={{ x: 50, y: 20 }} />
</>
)
export const manAsset = (
<TL.asset.image
w={100}
h={200}
src="/man.png"
name="man"
isAnimated={false}
mimeType="image/png"
/>
)
export const manCrop: TLShapeCrop = {
topLeft: { x: 0.25, y: 0.05 },
bottomRight: { x: 0.75, y: 0.3 },
}
export const manCropAsCircle: TLShapeCrop = {
topLeft: { x: 0.25, y: 0.05 },
bottomRight: { x: 0.75, y: 0.3 },
isCircle: true,
}
export const richText = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { dir: 'auto', level: 3 },
content: [{ type: 'text', text: 'Headers for things that wrap across multiple lines' }],
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: { dir: 'auto' },
content: [{ type: 'text', text: 'testing 123' }],
},
],
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
attrs: { dir: 'auto' },
content: [
{ type: 'text', text: 'lists and ' },
{
type: 'text',
marks: [
{
type: 'link',
attrs: {
href: 'https://tldraw.dev',
target: '_blank',
rel: 'noopener noreferrer nofollow',
class: null,
},
},
],
text: 'links',
},
],
},
],
},
],
},
{
type: 'paragraph',
attrs: { dir: 'auto' },
content: [
{ type: 'text', marks: [{ type: 'bold' }], text: 'and' },
{ type: 'text', text: ' ' },
{ type: 'text', marks: [{ type: 'strike' }], text: 'obvs' },
{ type: 'text', text: ' ' },
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
{ type: 'text', text: ' ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
],
},
{
type: 'paragraph',
attrs: { dir: 'auto' },
content: [
{ type: 'text', marks: [{ type: 'highlight' }], text: 'highlight ' },
{ type: 'text', marks: [{ type: 'highlight' }, { type: 'bold' }], text: 'across' },
{ type: 'text', marks: [{ type: 'highlight' }], text: ' styles' },
],
},
],
}
export const richTextForArrow = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { dir: 'auto', level: 3 },
content: [{ type: 'text', text: 'Headers' }],
},
{
type: 'paragraph',
attrs: { dir: 'auto' },
content: [
{ type: 'text', marks: [{ type: 'highlight' }], text: 'rich' },
{ type: 'text', text: ' ' },
{ type: 'text', marks: [{ type: 'code' }], text: 'text' },
],
},
],
}
export const convexDrawShape = (
<TL.draw
x={0.62}
y={0}
ref="convex"
color="black"
fill="none"
isClosed={false}
isComplete={true}
isPen={false}
size="m"
segments={[
{
type: 'free',
path: b64Vecs.encodePoints(convexPoints),
},
]}
/>
)
export const heyDrawShape = (
<TL.draw
x={0}
y={39.65841178508872}
ref="hey"
color="black"
fill="none"
isClosed={false}
isComplete={true}
isPen={false}
size="m"
segments={[
{
type: 'free',
path: b64Vecs.encodePoints([
{ x: 0, y: 0, z: 0.5 },
{ x: 0.12, y: 0, z: 0.5 },
{ x: 0.91, y: -0.18, z: 0.5 },
{ x: 2.51, y: -0.94, z: 0.5 },
{ x: 4.65, y: -2.47, z: 0.5 },
{ x: 7.11, y: -4.78, z: 0.5 },
{ x: 9.84, y: -7.98, z: 0.5 },
{ x: 13.03, y: -12.61, z: 0.5 },
{ x: 15.78, y: -17.73, z: 0.5 },
{ x: 17.93, y: -22.87, z: 0.5 },
{ x: 19.42, y: -27.3, z: 0.5 },
{ x: 20.11, y: -30.11, z: 0.5 },
{ x: 20.53, y: -32.62, z: 0.5 },
{ x: 20.66, y: -35.23, z: 0.5 },
{ x: 20.54, y: -36.97, z: 0.5 },
{ x: 19.97, y: -38.12, z: 0.5 },
{ x: 18.95, y: -39.25, z: 0.5 },
{ x: 17.82, y: -39.85, z: 0.5 },
{ x: 16.59, y: -40.07, z: 0.5 },
{ x: 15.25, y: -40.08, z: 0.5 },
{ x: 13.84, y: -39.7, z: 0.5 },
{ x: 12.48, y: -38.72, z: 0.5 },
{ x: 11.21, y: -37.3, z: 0.5 },
{ x: 10.07, y: -35.5, z: 0.5 },
{ x: 9.14, y: -33.38, z: 0.5 },
{ x: 8.51, y: -30.99, z: 0.5 },
{ x: 8.19, y: -28.34, z: 0.5 },
{ x: 8.11, y: -25.55, z: 0.5 },
{ x: 8.11, y: -22.75, z: 0.5 },
{ x: 8.31, y: -20.23, z: 0.5 },
{ x: 8.83, y: -17.85, z: 0.5 },
{ x: 9.49, y: -15.27, z: 0.5 },
{ x: 10.15, y: -12.53, z: 0.5 },
{ x: 10.77, y: -9.56, z: 0.5 },
{ x: 11.29, y: -6.5, z: 0.5 },
{ x: 11.74, y: -3.53, z: 0.5 },
{ x: 12.19, y: -0.31, z: 0.5 },
{ x: 12.52, y: 2.88, z: 0.5 },
{ x: 12.75, y: 5.9, z: 0.5 },
{ x: 12.95, y: 8.95, z: 0.5 },
{ x: 13.03, y: 11.73, z: 0.5 },
{ x: 13.03, y: 14.14, z: 0.5 },
{ x: 12.83, y: 16.09, z: 0.5 },
{ x: 12.23, y: 17.74, z: 0.5 },
{ x: 11.4, y: 19.09, z: 0.5 },
{ x: 10.36, y: 20.15, z: 0.5 },
{ x: 9.08, y: 20.99, z: 0.5 },
{ x: 7.73, y: 21.59, z: 0.5 },
{ x: 6.27, y: 21.97, z: 0.5 },
{ x: 4.82, y: 22.08, z: 0.5 },
{ x: 3.52, y: 21.99, z: 0.5 },
{ x: 2.39, y: 21.44, z: 0.5 },
{ x: 1.49, y: 20.34, z: 0.5 },
{ x: 0.89, y: 18.96, z: 0.5 },
{ x: 0.6, y: 17.5, z: 0.5 },
{ x: 0.54, y: 16.08, z: 0.5 },
{ x: 0.62, y: 14.79, z: 0.5 },
{ x: 1.13, y: 13.53, z: 0.5 },
{ x: 2.13, y: 12.22, z: 0.5 },
{ x: 3.56, y: 10.8, z: 0.5 },
{ x: 5.39, y: 9.18, z: 0.5 },
{ x: 7.52, y: 7.49, z: 0.5 },
{ x: 9.94, y: 5.75, z: 0.5 },
{ x: 12.46, y: 3.98, z: 0.5 },
{ x: 15.02, y: 2.32, z: 0.5 },
{ x: 17.66, y: 0.54, z: 0.5 },
{ x: 20.2, y: -1.35, z: 0.5 },
{ x: 22.87, y: -3.45, z: 0.5 },
{ x: 25.3, y: -5.53, z: 0.5 },
{ x: 27.23, y: -7.49, z: 0.5 },
{ x: 29.05, y: -9.64, z: 0.5 },
{ x: 30.87, y: -12.22, z: 0.5 },
{ x: 32.68, y: -15.35, z: 0.5 },
{ x: 34.02, y: -18.4, z: 0.5 },
{ x: 34.93, y: -21.39, z: 0.5 },
{ x: 35.63, y: -24.49, z: 0.5 },
{ x: 35.99, y: -27.13, z: 0.5 },
{ x: 36.04, y: -28.96, z: 0.5 },
{ x: 35.83, y: -30.34, z: 0.5 },
{ x: 35.35, y: -31.44, z: 0.5 },
{ x: 34.79, y: -32.04, z: 0.5 },
{ x: 34.24, y: -32.33, z: 0.5 },
{ x: 33.61, y: -32.41, z: 0.5 },
{ x: 32.88, y: -32.36, z: 0.5 },
{ x: 31.96, y: -31.95, z: 0.5 },
{ x: 30.84, y: -30.99, z: 0.5 },
{ x: 29.69, y: -29.69, z: 0.5 },
{ x: 28.62, y: -28.16, z: 0.5 },
{ x: 27.7, y: -26.41, z: 0.5 },
{ x: 26.93, y: -24.43, z: 0.5 },
{ x: 26.3, y: -22.19, z: 0.5 },
{ x: 25.81, y: -19.62, z: 0.5 },
{ x: 25.34, y: -16.39, z: 0.5 },
{ x: 25.02, y: -13.18, z: 0.5 },
{ x: 24.83, y: -9.63, z: 0.5 },
{ x: 24.69, y: -6.01, z: 0.5 },
{ x: 24.69, y: -3.02, z: 0.5 },
{ x: 24.69, y: 0, z: 0.5 },
{ x: 24.8, y: 2.74, z: 0.5 },
{ x: 25.21, y: 5.11, z: 0.5 },
{ x: 25.88, y: 7.15, z: 0.5 },
{ x: 26.62, y: 8.87, z: 0.5 },
{ x: 27.33, y: 10.2, z: 0.5 },
{ x: 27.95, y: 11.12, z: 0.5 },
{ x: 28.56, y: 11.81, z: 0.5 },
{ x: 29.21, y: 12.34, z: 0.5 },
{ x: 29.9, y: 12.72, z: 0.5 },
{ x: 30.76, y: 13.03, z: 0.5 },
{ x: 31.83, y: 13.23, z: 0.5 },
{ x: 32.97, y: 13.28, z: 0.5 },
{ x: 34.31, y: 13.22, z: 0.5 },
{ x: 35.78, y: 12.84, z: 0.5 },
{ x: 37.27, y: 12.05, z: 0.5 },
{ x: 38.69, y: 11.06, z: 0.5 },
{ x: 39.92, y: 9.99, z: 0.5 },
{ x: 41.08, y: 8.71, z: 0.5 },
{ x: 42.14, y: 7.13, z: 0.5 },
{ x: 43.03, y: 5.41, z: 0.5 },
{ x: 43.74, y: 3.61, z: 0.5 },
{ x: 44.23, y: 1.73, z: 0.5 },
{ x: 44.51, y: -0.23, z: 0.5 },
{ x: 44.62, y: -2.13, z: 0.5 },
{ x: 44.63, y: -3.61, z: 0.5 },
{ x: 44.47, y: -4.88, z: 0.5 },
{ x: 44.07, y: -5.88, z: 0.5 },
{ x: 43.5, y: -6.42, z: 0.5 },
{ x: 42.87, y: -6.81, z: 0.5 },
{ x: 42.29, y: -7, z: 0.5 },
{ x: 41.76, y: -7.06, z: 0.5 },
{ x: 41.16, y: -6.99, z: 0.5 },
{ x: 40.49, y: -6.64, z: 0.5 },
{ x: 39.85, y: -5.97, z: 0.5 },
{ x: 39.23, y: -5.13, z: 0.5 },
{ x: 38.52, y: -4.11, z: 0.5 },
{ x: 37.85, y: -3, z: 0.5 },
{ x: 37.29, y: -2, z: 0.5 },
{ x: 36.75, y: -1.06, z: 0.5 },
{ x: 36.29, y: -0.14, z: 0.5 },
{ x: 35.94, y: 0.72, z: 0.5 },
{ x: 35.66, y: 1.75, z: 0.5 },
{ x: 35.49, y: 2.84, z: 0.5 },
{ x: 35.4, y: 3.93, z: 0.5 },
{ x: 35.32, y: 5.19, z: 0.5 },
{ x: 35.28, y: 6.54, z: 0.5 },
{ x: 35.28, y: 7.9, z: 0.5 },
{ x: 35.33, y: 9.08, z: 0.5 },
{ x: 35.5, y: 10.03, z: 0.5 },
{ x: 35.79, y: 10.87, z: 0.5 },
{ x: 36.16, y: 11.56, z: 0.5 },
{ x: 36.61, y: 12.13, z: 0.5 },
{ x: 37.13, y: 12.61, z: 0.5 },
{ x: 37.76, y: 13.1, z: 0.5 },
{ x: 38.4, y: 13.58, z: 0.5 },
{ x: 39.1, y: 13.96, z: 0.5 },
{ x: 39.95, y: 14.36, z: 0.5 },
{ x: 40.84, y: 14.69, z: 0.5 },
{ x: 41.69, y: 14.88, z: 0.5 },
{ x: 42.57, y: 15.02, z: 0.5 },
{ x: 43.6, y: 15.1, z: 0.5 },
{ x: 44.69, y: 15.08, z: 0.5 },
{ x: 45.75, y: 14.8, z: 0.5 },
{ x: 46.84, y: 14.18, z: 0.5 },
{ x: 47.96, y: 13.35, z: 0.5 },
{ x: 49.03, y: 12.36, z: 0.5 },
{ x: 50, y: 11.23, z: 0.5 },
{ x: 50.83, y: 9.93, z: 0.5 },
{ x: 51.56, y: 8.45, z: 0.5 },
{ x: 52.22, y: 6.81, z: 0.5 },
{ x: 52.72, y: 5.09, z: 0.5 },
{ x: 53.07, y: 3.35, z: 0.5 },
{ x: 53.36, y: 1.6, z: 0.5 },
{ x: 53.6, y: -0.35, z: 0.5 },
{ x: 53.8, y: -2.37, z: 0.5 },
{ x: 53.98, y: -4.06, z: 0.5 },
{ x: 54.15, y: -5.39, z: 0.5 },
{ x: 54.3, y: -6.4, z: 0.5 },
{ x: 54.37, y: -6.95, z: 0.5 },
{ x: 54.37, y: -7.18, z: 0.5 },
{ x: 54.37, y: -7.21, z: 0.5 },
{ x: 54.37, y: -7.12, z: 0.5 },
{ x: 54.37, y: -6.92, z: 0.5 },
{ x: 54.37, y: -6.43, z: 0.5 },
{ x: 54.37, y: -5.71, z: 0.5 },
{ x: 54.37, y: -4.8, z: 0.5 },
{ x: 54.37, y: -3.66, z: 0.5 },
{ x: 54.37, y: -2.34, z: 0.5 },
{ x: 54.37, y: -0.89, z: 0.5 },
{ x: 54.37, y: 0.58, z: 0.5 },
{ x: 54.46, y: 2.04, z: 0.5 },
{ x: 54.69, y: 3.49, z: 0.5 },
{ x: 55.02, y: 4.89, z: 0.5 },
{ x: 55.41, y: 6.29, z: 0.5 },
{ x: 55.84, y: 7.62, z: 0.5 },
{ x: 56.32, y: 8.89, z: 0.5 },
{ x: 56.86, y: 10.2, z: 0.5 },
{ x: 57.49, y: 11.48, z: 0.5 },
{ x: 58.11, y: 12.51, z: 0.5 },
{ x: 58.86, y: 13.46, z: 0.5 },
{ x: 59.75, y: 14.33, z: 0.5 },
{ x: 60.69, y: 14.83, z: 0.5 },
{ x: 61.74, y: 15.08, z: 0.5 },
{ x: 62.7, y: 15.21, z: 0.5 },
{ x: 63.7, y: 15.24, z: 0.5 },
{ x: 64.66, y: 15.06, z: 0.5 },
{ x: 65.42, y: 14.5, z: 0.5 },
{ x: 66.09, y: 13.69, z: 0.5 },
{ x: 66.63, y: 12.77, z: 0.5 },
{ x: 67.1, y: 11.64, z: 0.5 },
{ x: 67.55, y: 10.06, z: 0.5 },
{ x: 68.09, y: 8.03, z: 0.5 },
{ x: 68.8, y: 5.7, z: 0.5 },
{ x: 69.55, y: 3.22, z: 0.5 },
{ x: 70.24, y: 0.97, z: 0.5 },
{ x: 70.7, y: -0.61, z: 0.5 },
{ x: 71, y: -1.73, z: 0.5 },
{ x: 71.23, y: -2.55, z: 0.5 },
{ x: 71.36, y: -2.95, z: 0.5 },
{ x: 71.42, y: -3.12, z: 0.5 },
{ x: 71.44, y: -3.17, z: 0.5 },
{ x: 71.46, y: -3.15, z: 0.5 },
{ x: 71.49, y: -2.95, z: 0.5 },
{ x: 71.53, y: -2.34, z: 0.5 },
{ x: 71.53, y: -1.18, z: 0.5 },
{ x: 71.53, y: 0.66, z: 0.5 },
{ x: 71.5, y: 2.95, z: 0.5 },
{ x: 71.33, y: 5.58, z: 0.5 },
{ x: 70.99, y: 8.57, z: 0.5 },
{ x: 70.53, y: 11.96, z: 0.5 },
{ x: 70.11, y: 15.29, z: 0.5 },
{ x: 69.82, y: 18.28, z: 0.5 },
{ x: 69.7, y: 21.19, z: 0.5 },
{ x: 69.69, y: 23.9, z: 0.5 },
{ x: 69.69, y: 26.33, z: 0.5 },
{ x: 69.69, y: 28.61, z: 0.5 },
{ x: 69.69, y: 30.96, z: 0.5 },
{ x: 69.67, y: 33.31, z: 0.5 },
{ x: 69.49, y: 35.59, z: 0.5 },
{ x: 69.12, y: 37.74, z: 0.5 },
{ x: 68.62, y: 39.65, z: 0.5 },
{ x: 67.98, y: 41.3, z: 0.5 },
{ x: 66.98, y: 42.84, z: 0.5 },
{ x: 65.6, y: 44.27, z: 0.5 },
{ x: 63.92, y: 45.56, z: 0.5 },
{ x: 62.03, y: 46.71, z: 0.5 },
{ x: 60.18, y: 47.41, z: 0.5 },
{ x: 58.36, y: 47.69, z: 0.5 },
{ x: 56.55, y: 47.57, z: 0.5 },
{ x: 54.75, y: 46.87, z: 0.5 },
{ x: 53.04, y: 45.75, z: 0.5 },
{ x: 51.51, y: 44.61, z: 0.5 },
{ x: 50.46, y: 43.69, z: 0.5 },
{ x: 49.77, y: 42.93, z: 0.5 },
{ x: 49.16, y: 42.03, z: 0.5 },
{ x: 48.84, y: 41.13, z: 0.5 },
{ x: 48.78, y: 40.33, z: 0.5 },
{ x: 48.78, y: 39.54, z: 0.5 },
{ x: 48.86, y: 38.75, z: 0.5 },
{ x: 49.28, y: 38.01, z: 0.5 },
{ x: 50.19, y: 37.36, z: 0.5 },
{ x: 51.56, y: 36.7, z: 0.5 },
{ x: 53.41, y: 36.07, z: 0.5 },
{ x: 55.71, y: 35.44, z: 0.5 },
{ x: 58.21, y: 34.85, z: 0.5 },
{ x: 60.57, y: 34.32, z: 0.5 },
{ x: 62.93, y: 33.75, z: 0.5 },
{ x: 65.29, y: 33.12, z: 0.5 },
{ x: 67.46, y: 32.5, z: 0.5 },
{ x: 69.41, y: 31.8, z: 0.5 },
{ x: 71.38, y: 30.83, z: 0.5 },
{ x: 73.7, y: 29.37, z: 0.5 },
{ x: 76.04, y: 27.45, z: 0.5 },
{ x: 78.48, y: 24.96, z: 0.5 },
{ x: 81.07, y: 21.89, z: 0.5 },
{ x: 83.32, y: 18.78, z: 0.5 },
{ x: 85.52, y: 15.01, z: 0.5 },
{ x: 87.47, y: 10.56, z: 0.5 },
{ x: 88.82, y: 5.98, z: 0.5 },
{ x: 89.82, y: 1.05, z: 0.5 },
{ x: 90.62, y: -4.02, z: 0.5 },
{ x: 91.26, y: -9.09, z: 0.5 },
{ x: 91.6, y: -14.06, z: 0.5 },
{ x: 91.65, y: -19.16, z: 0.5 },
{ x: 91.29, y: -24.1, z: 0.5 },
{ x: 90.46, y: -27.87, z: 0.5 },
{ x: 89.23, y: -30.92, z: 0.5 },
{ x: 87.67, y: -33.59, z: 0.5 },
{ x: 85.98, y: -35.5, z: 0.5 },
{ x: 84.22, y: -36.87, z: 0.5 },
{ x: 82.48, y: -37.85, z: 0.5 },
{ x: 80.81, y: -38.35, z: 0.5 },
{ x: 79.14, y: -38.5, z: 0.5 },
{ x: 77.52, y: -38.52, z: 0.5 },
{ x: 75.92, y: -38.28, z: 0.5 },
{ x: 74.29, y: -37.58, z: 0.5 },
{ x: 72.65, y: -36.53, z: 0.5 },
{ x: 70.95, y: -35.18, z: 0.5 },
{ x: 69.35, y: -33.68, z: 0.5 },
{ x: 68.08, y: -32.31, z: 0.5 },
{ x: 67.09, y: -31.05, z: 0.5 },
{ x: 66.32, y: -29.84, z: 0.5 },
{ x: 65.83, y: -28.72, z: 0.5 },
{ x: 65.59, y: -27.68, z: 0.5 },
{ x: 65.52, y: -26.71, z: 0.5 },
{ x: 65.52, y: -25.66, z: 0.5 },
{ x: 65.54, y: -24.54, z: 0.5 },
{ x: 65.78, y: -23.6, z: 0.5 },
{ x: 66.37, y: -22.77, z: 0.5 },
{ x: 67.14, y: -22.04, z: 0.5 },
{ x: 67.87, y: -21.52, z: 0.5 },
{ x: 68.61, y: -21.2, z: 0.5 },
{ x: 69.37, y: -21.04, z: 0.5 },
{ x: 70.19, y: -21, z: 0.5 },
{ x: 71.02, y: -21.2, z: 0.5 },
{ x: 71.71, y: -21.75, z: 0.5 },
{ x: 72.38, y: -22.5, z: 0.5 },
{ x: 73.03, y: -23.28, z: 0.5 },
{ x: 73.54, y: -24.03, z: 0.5 },
{ x: 73.92, y: -24.68, z: 0.5 },
{ x: 74.16, y: -25.17, z: 0.5 },
{ x: 74.29, y: -25.47, z: 0.5 },
{ x: 74.39, y: -25.67, z: 0.5 },
{ x: 74.43, y: -25.8, z: 0.5 },
{ x: 74.43, y: -25.88, z: 0.5 },
{ x: 74.43, y: -25.96, z: 0.5 },
{ x: 74.43, y: -26.06, z: 0.5 },
{ x: 74.43, y: -26.16, z: 0.5 },
{ x: 74.43, y: -26.26, z: 0.5 },
{ x: 74.43, y: -26.34, z: 0.5 },
{ x: 74.43, y: -26.4, z: 0.5 },
]),
},
]}
/>
)

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,328 @@
import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupPage } from '../shared-e2e'
declare const editor: Editor
test.describe('A11y Style Panel Navigation', () => {
test.beforeEach(async ({ page }) => {
await setupPage(page)
})
test('can navigate between geo shape label editing and style panel with keyboard', async ({
page,
isMobile,
}) => {
if (isMobile) {
// Skip on mobile as the keyboard navigation behavior is different
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
// Verify we have a geo shape selected
const selectedShapes = await page.evaluate(() => editor.getSelectedShapes())
expect(selectedShapes).toHaveLength(1)
expect(selectedShapes[0].type).toBe('geo')
// Step 1: Press Enter to start editing the label
await page.keyboard.press('Enter')
// Verify we're in editing mode
const isEditingShape = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isEditingShape).toBe(true)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Type some text
await page.keyboard.type('Test Label')
// Step 2: Press Escape to exit editing mode
await page.keyboard.press('Escape')
// Verify we're no longer editing
const isStillEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isStillEditing).toBe(false)
// Step 3: Press Cmd+Enter to focus the style panel
await page.keyboard.press('Meta+Enter')
// Verify that the first button in the style panel is focused
await expect
.poll(() =>
page.evaluate(() => {
const activeEl = document.activeElement
return activeEl?.closest('.tlui-style-panel') !== null && activeEl?.tagName === 'BUTTON'
})
)
.toBe(true)
// Step 4: Use arrow keys to navigate to a different color (assuming color buttons are first)
await page.keyboard.press('ArrowRight')
// Verify focus has moved to the next button
await expect
.poll(() =>
page.evaluate(() => {
const activeEl = document.activeElement
return activeEl?.tagName === 'BUTTON' && activeEl?.closest('.tlui-style-panel') !== null
})
)
.toBe(true)
// Step 5: Press Enter to select the color
await page.keyboard.press('Enter')
// Step 6: Press Escape to return focus to the shape. The Escape keydown
// is what hands focus back to the canvas, and under load that single
// keypress can be dropped, so re-send it until focus actually settles.
await pressUntilFocusedOnCanvas(page)
// Step 7: Press Enter again to edit the label. Selecting a colour above makes
// the style panel re-render, and its roving-tabindex toolbar can bounce focus
// back onto the selected radio asynchronously — after Escape already returned
// focus to the canvas. So drive editing through whatever focus state we land
// in: Escape back to the canvas if focus bounced into the panel, otherwise
// Enter to start editing. The label text is fully replaced below, so a stray
// newline from a repeated Enter can't affect the result.
await startEditingSelectedShapeViaKeyboard(page)
// The rich text editor focuses its contenteditable on a deferred timeout, so
// wait for it to be the active element before typing or the first keystrokes
// get dropped (matching the wait used when first entering editing above).
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Replace the label text. Even once the contenteditable is focused, the
// editor's async setup can still drop the leading keystrokes, so select-all,
// type, and verify — retrying the whole select-and-replace until it sticks
// rather than asserting a single racy attempt.
await replaceSelectedShapeLabel(page, 'Updated Label')
// Exit editing
await page.keyboard.press('Escape')
})
// Skipped: the cmd+enter keyboard event isn't reliably picked up while editing rich text in the
// test harness, so the body passes intermittently. Because it was marked `test.fail`, those
// intermittent passes were reported as failures and flaked the suite. The behavior works in
// practice; re-enable once the event dispatch can be made deterministic (a reliable way to send
// the shortcut to the focused contenteditable).
test.skip('in a geo shape, cmd+enter returns focus to the canvas', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
// press enter
await page.keyboard.press('Enter')
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// expect editor to be in the editing state
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(true)
// dispatch cmd+enter to the active element
await page.keyboard.press('ControlOrMeta+Enter')
await page.waitForTimeout(100)
expect(await page.evaluate(() => editor.isIn('select.editing_shape'))).toBe(false)
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
})
test('in a note shape, cmd+enter creates the next shape and does not focus on the style panel', async ({
page,
isMobile,
}) => {
if (isMobile) {
test.skip()
}
// Create a note shape
await page.keyboard.press('n') // Note tool
await page.mouse.click(200, 200)
await page.waitForTimeout(200)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
const firstShapeId = await page.evaluate(() => {
return editor.getOnlySelectedShape()?.id
})
expect(firstShapeId).toBeDefined()
// Press cmd+enter
await page.keyboard.press('Meta+Enter')
// Should be on a new shape now
const finalShapeId = await page.evaluate(() => {
return editor.getOnlySelectedShape()?.id
})
expect(finalShapeId).toBeDefined()
expect(finalShapeId).not.toBe(firstShapeId)
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(false)
await expect.poll(() => isFocusedOnCanvas(page)).toBe(false)
await expect.poll(() => isFocusedOnRichTextEditor(page)).toBe(true)
// Press escape
await pressUntilFocusedOnCanvas(page)
})
test('style panel focus works with different geo shapes', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Test with ellipse
await page.keyboard.press('o') // Ellipse tool
await page.mouse.click(300, 300)
await page.keyboard.press('v') // Select tool
// Verify we can focus style panel
await page.keyboard.press('Meta+Enter')
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Return to shape and test editing
await page.keyboard.press('Escape')
await page.keyboard.press('Enter')
const isEditing = await page.evaluate(() => editor.getEditingShapeId() !== null)
expect(isEditing).toBe(true)
})
test('style panel focus works with cmd+enter shortcut', async ({ page, isMobile }) => {
if (isMobile) {
test.skip()
}
// Create a geo shape
await page.keyboard.press('r') // Rectangle tool
await page.mouse.click(200, 200)
await page.keyboard.press('v') // Select tool
await expect.poll(() => isFocusedOnCanvas(page)).toBe(true)
// Test that cmd+enter focuses the style panel even without editing first
await page.keyboard.press('Meta+Enter')
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Navigate in the style panel
await page.keyboard.press('ArrowRight')
// Verify focus is still in style panel
await expect.poll(() => isFocusedOnStylePanel(page)).toBe(true)
// Return to canvas. The Escape keydown is what restores focus to the
// canvas, and that single keypress can be dropped under load, so re-send
// it until focus actually settles instead of asserting once.
await pressUntilFocusedOnCanvas(page)
})
})
// Escape returns focus to the canvas from wherever it currently is — the style
// panel (see DefaultStylePanel's keydown handler, which calls
// editor.getContainer().focus() synchronously and stops propagation) or a shape's
// rich text editor (Escape exits editing). The transition is instant, so when focus
// fails to settle it's because the single Escape keypress was dropped under load
// rather than because it was slow — so re-send it until focus returns to the canvas.
// The early return means we never press while already on the canvas, so a stray
// repeat can't deselect the shape.
async function pressUntilFocusedOnCanvas(page: Page) {
await expect
.poll(
async () => {
if (await isFocusedOnCanvas(page)) return true
await page.keyboard.press('Escape')
return isFocusedOnCanvas(page)
},
{ timeout: 5000, intervals: [100, 200, 300, 500] }
)
.toBe(true)
}
// Start editing the selected shape's label with the keyboard, tolerating both a
// dropped keypress and the style panel's roving-tabindex focus bounce. Each round:
// if focus has landed back in the style panel, Escape returns it to the canvas;
// otherwise Enter starts editing the selected shape. We only press Enter while not
// already editing, so we never inject a stray newline into an active label.
async function startEditingSelectedShapeViaKeyboard(page: Page) {
const isEditing = () => page.evaluate(() => editor.getEditingShapeId() !== null)
await expect
.poll(
async () => {
if (await isEditing()) return true
const isInStylePanel = await page.evaluate(
() => document.activeElement?.closest('.tlui-style-panel') !== null
)
await page.keyboard.press(isInStylePanel ? 'Escape' : 'Enter')
return isEditing()
},
{ timeout: 8000, intervals: [100, 200, 300, 500] }
)
.toBe(true)
}
// Select all of the editing shape's label text and replace it, tolerating dropped
// keystrokes during the editor's async setup. Each attempt re-selects everything
// and retypes, so a partially-typed result from a previous attempt is fully
// overwritten rather than appended to — the poll converges once nothing drops.
// The select-all must be ControlOrMeta: plain Meta+a selects nothing on Linux, so
// every retry would append another copy instead of replacing.
async function replaceSelectedShapeLabel(page: Page, text: string) {
const getText = () =>
page.evaluate(() => {
const shape = editor.getSelectedShapes()[0] as any
return editor.getShapeUtil(shape).getText(shape)
})
await expect
.poll(
async () => {
await page.keyboard.press('ControlOrMeta+a')
await page.keyboard.type(text)
return getText()
},
{ timeout: 8000, intervals: [100, 200, 300, 500] }
)
.toBe(text)
}
async function isFocusedOnStylePanel(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
// check that the active element is within both the editor container and the style panel
activeEl?.role === 'radio' && activeEl?.closest('.tlui-style-panel') !== null
)
})
}
async function isFocusedOnRichTextEditor(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
activeEl?.role === 'textbox' &&
activeEl?.closest('.tl-container') !== null &&
activeEl?.closest('.tlui-style-panel') === null
)
})
}
async function isFocusedOnCanvas(page: Page) {
return await page.evaluate(() => {
const activeEl = document.activeElement
return (
activeEl?.role === 'application' &&
activeEl?.closest('.tl-container') !== null &&
activeEl?.closest('.tlui-style-panel') === null
)
})
}

View File

@@ -0,0 +1,20 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('actions menu', () => {
test.beforeEach(setupOrReset)
test('you can open and close the actions menu', async ({ actionsMenu, menuClickCapture }) => {
const { actionsMenuButton, actionsMenuContent } = actionsMenu
await expect(actionsMenuContent).toBeHidden()
await actionsMenuButton.click()
await expect(actionsMenuContent).toBeVisible()
await menuClickCapture.content.click()
await expect(actionsMenuContent).toBeHidden()
})
// ...
// More tests here
// ...
})

View File

@@ -0,0 +1,21 @@
import test from '../fixtures/fixtures'
import { hardResetWithShapes, setupWithShapes } from '../shared-e2e'
test.beforeEach(async ({ page, context }) => {
const url = page.url()
if (!url.includes('end-to-end')) {
await setupWithShapes({ page, context } as any)
} else {
await hardResetWithShapes(page)
}
})
test.describe('api', () => {
for (const format of ['svg', 'png', 'jpeg', 'webp'] as const) {
test(`export as ${format}`, async ({ page, api }) => {
const downloadEvent = page.waitForEvent('download')
await api.exportAsFormat(format)
await downloadEvent
})
}
})

View File

@@ -0,0 +1,256 @@
import { CDPSession, Page, expect } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset, sleep } from '../shared-e2e'
declare const editor: Editor
let client: CDPSession
const dispatchTouch = async (
client: CDPSession,
type: 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel',
touchPoints: { x: number; y: number }[]
) => {
await client.send('Input.dispatchTouchEvent', { type, touchPoints })
}
const multiTouchGesture = async ({
start,
end,
steps,
}: {
start: { x: number; y: number }[]
end: { x: number; y: number }[]
steps: number
}) => {
const finger1 = { x: start[0].x, y: start[0].y }
const finger2 = { x: start[1].x, y: start[1].y }
const finalTouch = [finger1, finger2]
await dispatchTouch(client, 'touchStart', [finger1, finger2])
await sleep(10)
// how far should the fingers move each step to reach the end point?
const finger1StepDistance = {
x: (end[0].x - start[0].x) / steps,
y: (end[0].y - start[0].y) / steps,
}
const finger2StepDistance = {
x: (end[1].x - start[1].x) / steps,
y: (end[1].y - start[1].y) / steps,
}
for (let i = 1; i < steps; i++) {
await dispatchTouch(client, 'touchMove', [
{ x: finger1.x + i * finger1StepDistance.x, y: finger1.y + i * finger1StepDistance.y },
{ x: finger2.x + i * finger2StepDistance.x, y: finger2.y + i * finger2StepDistance.y },
])
await sleep(10)
}
await dispatchTouch(client, 'touchEnd', finalTouch)
await sleep(10)
}
const scrollZoom = async ({
page,
currentZoomLevel,
zoomDirection,
steps,
}: {
page: Page
currentZoomLevel: number
zoomDirection: 'in' | 'out'
steps: number
}) => {
page.keyboard.down('Control')
// zooming with the scroll wheel is capped within the editor to make sure it's consistent between browsers,
//scrolling further than 15px per event doesn't have any effect
for (let i = 0; i < steps; i++) {
await page.mouse.wheel(0, zoomDirection === 'out' ? -15 : 15)
}
page.keyboard.up('Control')
// scrolling 15px on the mouse wheel changes the zoom level by 10%
const expectedZoomLevel =
zoomDirection === 'out' ? currentZoomLevel * 1.1 ** steps : currentZoomLevel / 1.1 ** steps
return expectedZoomLevel
}
test.describe('camera', () => {
test.beforeEach(setupOrReset)
test('panning', async ({ isMobile, page }) => {
test.skip(!isMobile)
client = await page.context().newCDPSession(page)
expect(await page.evaluate(() => editor.inputs.getCurrentPagePoint())).toEqual({
x: 50,
y: 50,
z: 0,
})
// the fewer steps you use, the faster the pan. So the ratio of pixels moved is not one-to-one.
await multiTouchGesture({
start: [
{ x: 50, y: 50 },
{ x: 100, y: 100 },
],
end: [
{ x: 100, y: 100 },
{ x: 150, y: 150 },
],
steps: 50,
})
// With current panning speed, the above gesture moves the camera by ~36px on each axis.
// Use a tolerance to avoid brittleness across engines/emulation.
const [x, y] = await page.evaluate(() => {
const point = editor.inputs.getCurrentPagePoint()
return [point.x, point.y]
})
expect(x).toBeGreaterThan(80)
expect(x).toBeLessThan(95)
expect(y).toBeGreaterThan(80)
expect(y).toBeLessThan(95)
})
test('pinching on trackpad', async ({ page, isMobile }) => {
// Test is flaky, disabling.
test.skip(true)
// pinching on trackpad is the same event as ctrl+scrollwheel
test.skip(isMobile)
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
let expectedZoomLevel = await scrollZoom({
page,
currentZoomLevel: 1,
zoomDirection: 'in',
steps: 3,
})
// toBeCloseTo is used to avoid floating point errors
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
expectedZoomLevel = await scrollZoom({
page,
currentZoomLevel: expectedZoomLevel,
zoomDirection: 'out',
steps: 3,
})
expect(await page.evaluate(() => editor.getZoomLevel())).toBeCloseTo(expectedZoomLevel, 1)
})
test('pinching on touchscreen', async ({ page, isMobile }) => {
test.skip(!isMobile)
client = await page.context().newCDPSession(page)
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(1)
// zoom out
await multiTouchGesture({
start: [
{ x: 100, y: 100 },
{ x: 200, y: 200 },
],
end: [
{ x: 149, y: 149 },
{ x: 151, y: 151 },
],
steps: 50,
})
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(0.05)
// now zoom in
await multiTouchGesture({
start: [
{ x: 200, y: 200 },
{ x: 200, y: 200 },
],
end: [
{ x: 0, y: 0 },
{ x: 400, y: 400 },
],
steps: 50,
})
expect(await page.evaluate(() => editor.getZoomLevel())).toBe(8)
})
test('two-finger pinch does not change the selection', async ({ page, isMobile }) => {
test.skip(!isMobile)
client = await page.context().newCDPSession(page)
// Finger 1 lands here; finger 2 above it. Both are kept inside the mobile
// viewport so the gesture registers.
const f1 = { x: 196, y: 470, id: 0 }
const f2 = { x: 196, y: 300, id: 1 }
// Shape "a" (selected, off to the side) plus shape "b" placed directly under
// finger 1, so the first finger's pointer_down selects it.
await page.evaluate((f) => {
const p = editor.screenToPage({ x: f.x, y: f.y })
editor.createShapes([
{
id: 'shape:e2eA',
type: 'geo',
x: -1000,
y: -1000,
props: { w: 100, h: 100, fill: 'solid' },
},
{
id: 'shape:e2eB',
type: 'geo',
x: p.x - 60,
y: p.y - 60,
props: { w: 120, h: 120, fill: 'solid' },
},
] as any)
editor.setSelectedShapes(['shape:e2eA'] as any)
}, f1)
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
// Finger 1 down on shape b — through the real touch/pointer pipeline this
// changes the selection to b.
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1] })
await sleep(30)
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eB'])
// Finger 2 joins, then both fingers move apart to pinch-zoom, then lift.
await client.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [f1, f2] })
await sleep(30)
// The pinch has started, so the incidental selection is already rolled back —
// the original selection is shown during the gesture, not only after it ends.
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
const steps = 12
for (let i = 1; i <= steps; i++) {
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: f1.x, y: f1.y + i * 8, id: 0 }, // finger 1 moves down
{ x: f2.x, y: f2.y - i * 8, id: 1 }, // finger 2 moves up
],
})
await sleep(10)
}
await client.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await sleep(100)
// The incidental selection of b is rolled back to the pre-gesture selection.
expect(await page.evaluate(() => editor.getSelectedShapeIds())).toEqual(['shape:e2eA'])
// ...and the pinch really did zoom (sanity check that a pinch happened).
expect(await page.evaluate(() => editor.getZoomLevel())).not.toBe(1)
})
test.fixme('minimap', async () => {
// todo
})
test.fixme('hand tool', async () => {
// todo
})
})

View File

@@ -0,0 +1,153 @@
import test, { expect, Page } from '@playwright/test'
import { Editor } from 'tldraw'
import { setupPage } from '../shared-e2e'
declare const __tldraw_editor_events: any[]
// We're just testing the events, not the actual results.
let page: Page
declare const editor: Editor
test.describe('Canvas events', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test.describe('pointer events', () => {
test('pointer down', async () => {
await page.mouse.move(200, 200) // to kill any double clicks
await page.mouse.move(100, 100)
await page.mouse.down()
await page.waitForTimeout(20)
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(200, 200) // to kill any double clicks
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(101, 101)
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(200, 200) // to kill any double clicks
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(101, 101)
await page.mouse.up()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_up',
})
})
})
test.describe('click events', () => {
// todo
})
test.describe('pinch events', () => {
// todo
})
test.describe('keyboard events', () => {
// todo
})
test.fixme('wheel events', async () => {
await page.mouse.move(200, 200) // to kill any double clicks
await page.mouse.move(100, 100)
await page.mouse.wheel(10, 10)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
type: 'wheel',
name: 'wheel',
delta: {
x: -20,
y: -20,
},
})
})
test.fixme('complete', async () => {
await page.evaluate(async () => editor.complete())
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
type: 'misc',
name: 'complete',
})
})
test.fixme('cancel', async () => {
await page.evaluate(async () => editor.cancel())
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
type: 'misc',
name: 'complete',
})
})
test.fixme('interrupt', async () => {
await page.evaluate(async () => editor.interrupt())
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
type: 'misc',
name: 'interrupt',
})
})
})
test.describe('Shape events', () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
await page.keyboard.press('r')
await page.mouse.click(150, 150)
await page.mouse.move(0, 0)
await page.keyboard.press('Escape')
})
test('pointer down', async () => {
await page.mouse.move(51, 51)
await page.mouse.down()
await page.waitForTimeout(20)
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)
await page.waitForTimeout(20)
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()
await page.waitForTimeout(20)
expect(await page.evaluate(() => __tldraw_editor_events.at(-1))).toMatchObject({
target: 'canvas',
type: 'pointer',
name: 'pointer_up',
})
})
})

View File

@@ -0,0 +1,270 @@
import { expect } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { sleep } from '../shared-e2e'
declare const editor: Editor
test.describe('clipboard event callbacks', () => {
test.beforeEach(async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto('http://localhost:5420/clipboard-events/full')
await page.waitForSelector('.tl-canvas')
await page.evaluate(() => {
editor.user.updateUserPreferences({ animationSpeed: 0 })
})
})
test.beforeEach(async ({ page }) => {
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
;(window as any).__tldraw_clipboard_log = []
;(window as any).__tldraw_clipboard_state.disableCopy = false
;(window as any).__tldraw_clipboard_state.disablePaste = false
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = false
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = false
;(window as any).__tldraw_clipboard_state.handleRawPaste = false
})
await page.evaluate(() => {
editor.createShapes([{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100 } }])
editor.selectAll()
})
await page.locator('.tl-container').focus()
})
test('onBeforeCopyToClipboard runs on keyboard copy', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(100)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
expect(log).toHaveLength(1)
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: false })
})
test('onBeforeCopyToClipboard runs on keyboard cut', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyX')
await page.keyboard.up(modifier)
await sleep(100)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
expect(log).toHaveLength(1)
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: false })
})
test('onBeforePasteFromClipboard runs on keyboard paste', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(100)
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
await sleep(100)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
const pasteEntry = log.find((e: any) => e.action === 'paste')
expect(pasteEntry).toMatchObject({
action: 'paste',
source: 'native-event',
prevented: false,
})
})
test('onBeforeCopyToClipboard can block copy', async ({ page, isMac }) => {
await page.evaluate(() => {
;(window as any).__tldraw_clipboard_state.disableCopy = true
})
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(100)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
expect(log).toHaveLength(1)
expect(log[0]).toMatchObject({ action: 'copy', source: 'native', prevented: true })
})
test('onBeforePasteFromClipboard can block paste', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
// Copy normally first
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(100)
// Now block paste
await page.evaluate(() => {
;(window as any).__tldraw_clipboard_state.disablePaste = true
;(window as any).__tldraw_clipboard_log = []
})
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
await sleep(200)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
const pasteEntry = log.find((e: any) => e.action === 'paste')
expect(pasteEntry).toMatchObject({
action: 'paste',
source: 'native-event',
prevented: true,
})
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
expect(shapeCount).toBe(1)
})
test('onBeforeCopyToClipboard can block cut', async ({ page, isMac }) => {
await page.evaluate(() => {
;(window as any).__tldraw_clipboard_state.disableCopy = true
})
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyX')
await page.keyboard.up(modifier)
await sleep(100)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
expect(log).toHaveLength(1)
expect(log[0]).toMatchObject({ action: 'cut', source: 'native', prevented: true })
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
expect(shapeCount).toBe(1)
})
test('onBeforeCopyToClipboard can filter shapes from copy', async ({ page, isMac }) => {
// Create a red shape and a blue shape
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.createShapes([
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
])
editor.selectAll()
;(window as any).__tldraw_clipboard_log = []
;(window as any).__tldraw_clipboard_state.filterRedOnCopy = true
})
const modifier = isMac ? 'Meta' : 'Control'
// Copy (should filter out the red shape)
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(200)
// Delete all shapes, then paste
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
})
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
await sleep(200)
// Only the blue shape should have been pasted
const shapes = await page.evaluate(() =>
editor.getCurrentPageShapes().map((s: any) => s.props.color)
)
expect(shapes).toEqual(['blue'])
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
const filterEntry = log.find((e: any) => e.action === 'filter-copy')
expect(filterEntry).toMatchObject({
action: 'filter-copy',
detail: 'kept 1/2 shapes',
})
})
test('onBeforePasteFromClipboard can filter shapes on paste', async ({ page, isMac }) => {
// Create a red shape and a blue shape, then copy them normally
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.createShapes([
{ type: 'geo', x: 100, y: 100, props: { w: 100, h: 100, color: 'red' } },
{ type: 'geo', x: 300, y: 100, props: { w: 100, h: 100, color: 'blue' } },
])
editor.selectAll()
;(window as any).__tldraw_clipboard_log = []
})
const modifier = isMac ? 'Meta' : 'Control'
// Copy both shapes normally
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(200)
// Enable paste filter and delete all shapes
await page.evaluate(() => {
;(window as any).__tldraw_clipboard_state.filterRedOnPaste = true
;(window as any).__tldraw_clipboard_log = []
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
})
// Paste (should filter out the red shape on the paste side)
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
await sleep(200)
const shapes = await page.evaluate(() =>
editor.getCurrentPageShapes().map((s: any) => s.props.color)
)
expect(shapes).toEqual(['blue'])
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
const filterEntry = log.find((e: any) => e.action === 'filter-paste')
expect(filterEntry).toMatchObject({
action: 'filter-paste',
detail: 'kept 1/2 shapes',
})
})
test('onClipboardPasteRaw runs before parsed paste and can take over (keyboard)', async ({
page,
isMac,
}) => {
await page.evaluate(() => {
;(window as any).__tldraw_clipboard_state.handleRawPaste = true
;(window as any).__tldraw_clipboard_log = []
})
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await page.keyboard.up(modifier)
await sleep(100)
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
await sleep(200)
const log = await page.evaluate(() => (window as any).__tldraw_clipboard_log)
const rawEntry = log.find((e: any) => e.action === 'raw-paste')
expect(rawEntry).toMatchObject({ source: 'native-event' })
expect(rawEntry.detail).toContain('string')
const parsedPaste = log.find((e: any) => e.action === 'paste' && e.source === 'native-event')
expect(parsedPaste).toBeUndefined()
const shapeCount = await page.evaluate(() => editor.getCurrentPageShapes().length)
expect(shapeCount).toBe(1)
})
})

View File

@@ -0,0 +1,104 @@
import { expect } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { clickMenu, setup, sleep } from '../shared-e2e'
declare const editor: Editor
// these are skipped because they're flaky in CI :(
test.describe.skip('clipboard tests', () => {
test.beforeEach(setup)
test.beforeEach(async ({ page }) => {
await page.keyboard.press('r')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.up()
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(1)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
})
test('copy and paste from keyboard shortcut', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
await page.keyboard.down(modifier)
await page.keyboard.press('KeyC')
await sleep(100)
await page.keyboard.press('KeyV')
await page.keyboard.up(modifier)
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
})
test('copy and paste from main menu', async ({ page }) => {
await clickMenu(page, 'main-menu.edit.copy')
await sleep(100)
await clickMenu(page, 'main-menu.edit.paste')
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
})
test('copy and paste from context menu', async ({ page }) => {
await page.mouse.click(100, 100, { button: 'right' })
await clickMenu(page, 'context-menu.copy')
await sleep(100)
await page.mouse.move(200, 200)
await page.mouse.click(100, 100, { button: 'right' })
await clickMenu(page, 'context-menu.paste')
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getSelectedShapes().length)).toBe(1)
})
test('copy and paste png from context menu', async ({ page }) => {
await page.mouse.click(100, 100, { button: 'right' })
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
await sleep(100)
await page.mouse.move(400, 400)
await page.mouse.click(100, 100, { button: 'right' })
await clickMenu(page, 'context-menu.paste')
await sleep(100)
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
type: 'image',
props: { w: 264, h: 264 },
})
const imageWidth = await page
.locator('.tl-image')
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
expect(imageWidth).toBe(528)
})
test('copy png with context menu, paste with keyboard', async ({ page, isMac }) => {
const modifier = isMac ? 'Meta' : 'Control'
await page.mouse.click(100, 100, { button: 'right' })
await clickMenu(page, 'context-menu.copy-as.copy-as-png')
await sleep(100)
await page.keyboard.down(modifier)
await page.keyboard.press('KeyV')
await sleep(100)
await page.keyboard.up(modifier)
await sleep(100)
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
// image should come in at the same size (200x200 + padding)
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
type: 'image',
props: { w: 264, h: 264 },
})
// but the actual image should be 2x that for retina displays
const imageWidth = await page
.locator('.tl-image')
.evaluate((img) => (img as HTMLImageElement).naturalWidth)
expect(imageWidth).toBe(528)
})
})

View File

@@ -0,0 +1,219 @@
import { Page, expect } from '@playwright/test'
import test from '../fixtures/fixtures'
// Pick the comment tool and click the canvas, then wait for the composer to take focus before
// returning. Its autofocus runs on the next animation frame, so typing straight after the click
// can land before it and be dropped — no text, nothing to post, no pin.
async function startComment(page: Page) {
await page.getByTestId('quick-actions.comment').click()
await page.mouse.click(400, 300)
await expect(
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
).toBeFocused()
}
// Place a comment at the same point, post it, and dismiss the thread the send leaves open.
async function placeComment(page: Page, text: string) {
await startComment(page)
await page.keyboard.type(text)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
}
/**
* Canvas comment markers are the only way into a thread, so they have to be reachable without a
* pointer. These tests drive the commenting example with the keyboard alone — place a comment with
* the mouse (creating one is a canvas gesture), then never touch the mouse again.
*/
test.describe('commenting a11y', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/commenting/full')
await page.waitForSelector('.tl-canvas')
await page.locator('.tl-container').focus()
})
test('a comment pin is a labelled button that opens its thread from the keyboard', async ({
page,
isMobile,
}) => {
// Keyboard navigation isn't the mobile interaction model.
if (isMobile) test.skip()
// Place a comment: pick the comment tool, click the canvas, type, and send.
const commentTool = page.getByTestId('quick-actions.comment')
await commentTool.click()
await expect(commentTool).toHaveAttribute('aria-pressed', 'true')
await expect(commentTool).toHaveAttribute('data-isactive', 'true')
await page.mouse.click(400, 300)
await expect(
page.locator('.tlui-cmt-canvas-composer [contenteditable="true"].tlui-cmt-input')
).toBeFocused()
await page.keyboard.type('hello from the keyboard')
await page.keyboard.press('Enter')
// The pin exists and exposes button semantics with a name, not a bare div.
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
await expect(pin).toHaveCount(1)
await expect(pin).toHaveRole('button')
await expect(pin).toHaveAttribute('aria-label', /comment by/i)
// Dismiss the thread the send left open, so the pin starts closed.
await page.keyboard.press('Escape')
await expect(pin).toHaveAttribute('aria-expanded', 'false')
// Focusing the pin and pressing Enter opens the thread — no pointer involved.
await pin.focus()
await expect(pin).toBeFocused()
await page.keyboard.press('Enter')
await expect(pin).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
// Space toggles it back closed, as a button should.
await pin.focus()
await page.keyboard.press(' ')
await expect(pin).toHaveAttribute('aria-expanded', 'false')
})
test('the pin is reachable by tabbing, and the reply box has an accessible name', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await startComment(page)
// The composer is a contenteditable whose visible placeholder is aria-hidden, so without an
// explicit name a screen reader lands on an unlabelled textbox.
const composer = page.locator('.tlui-cmt-input').first()
await expect(composer).toHaveAttribute('aria-label', /.+/)
await expect(composer).toHaveRole('textbox')
await page.keyboard.type('first')
await page.keyboard.press('Enter')
const pin = page.locator('.tlui-cmt-canvas-pin__marker')
await page.keyboard.press('Escape')
// The pin participates in the tab order rather than being skipped as a div would be.
await page.locator('.tl-container').focus()
await expect
.poll(
async () => {
for (let i = 0; i < 25; i++) {
await page.keyboard.press('Tab')
if (await pin.evaluate((el) => el === document.activeElement)) return true
}
return false
},
{ timeout: 15000 }
)
.toBe(true)
})
test('a pin comes after the UI, so "move focus to canvas" keeps the first tab stop', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await placeComment(page, 'first')
await expect(page.locator('.tlui-cmt-canvas-pin__marker')).toHaveCount(1)
// The comments layer is portalled into the editor container, and a pin is a real button. Left
// where React drops the portal it lands ahead of the UI, taking the first tab stop from the
// skip link — which is then unreachable, and with it the keyboard route onto the canvas.
const tabOrder = await page.evaluate(() => {
const focusable = document.querySelectorAll(
'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]'
)
return {
firstIsSkipLink: focusable[0]?.classList.contains('tl-skip-to-main-content') ?? false,
pinIndex: [...focusable].findIndex((el) =>
el.classList.contains('tlui-cmt-canvas-pin__marker')
),
count: focusable.length,
}
})
expect(tabOrder.firstIsSkipLink).toBe(true)
expect(tabOrder.pinIndex).toBe(tabOrder.count - 1)
// And tabbing into the container really does land on the skip link first.
await page.locator('.tl-container').focus()
await page.keyboard.press('Tab')
await expect(page.locator('.tl-skip-to-main-content')).toBeFocused()
})
test('leaving the edit composer hands focus back to the control that opened it', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
await placeComment(page, 'first')
// Open the thread and spend its one Tab-into-the-reply-box, so nothing is left to pull focus
// back into the panel on its own — the state the escape has to survive.
await page.locator('.tlui-cmt-canvas-pin__marker').click()
await page.locator('.tl-container').focus()
await page.keyboard.press('Tab')
const reply = page.locator('.tlui-cmt-canvas-popover [contenteditable="true"].tlui-cmt-input')
await expect(reply).toBeFocused()
// Edit from the ⋯ menu: escaping should land back on the ⋯ button that opened it, not on the
// editor container, where Tab would walk the app's UI instead of the thread's own controls.
const moreButton = page.locator('.tlui-cmt-canvas-popover [data-cmt-more-for]')
await moreButton.click()
await page.locator('.tlui-cmt-menu-item', { hasText: 'Edit' }).click()
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
await expect(moreButton).toBeFocused()
// Arrow-up-to-edit comes from the reply box, so that's where escaping returns.
await reply.focus()
await page.keyboard.press('ArrowUp')
await expect(page.locator('.tlui-cmt-editing [contenteditable="true"]')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.locator('.tlui-cmt-canvas-popover')).toBeVisible()
await expect(reply).toBeFocused()
})
test('a stacked card is keyboard-openable without nesting its body links in a button', async ({
page,
isMobile,
}) => {
if (isMobile) test.skip()
// Two comments on the same point stack behind one badge. The first carries a link, which a
// comment body renders as a real anchor.
await placeComment(page, 'see https://tldraw.dev ')
await placeComment(page, 'second')
// Open the stack list from the keyboard.
const badge = page.locator('.tlui-cmt-canvas-stack-badge')
await expect(badge).toHaveCount(1)
await badge.focus()
await page.keyboard.press('Enter')
const card = page.locator('.tlui-cmt-stack-list__card').first()
await expect(card).toBeVisible()
await expect(card.locator('a[href]')).toHaveCount(1)
// A button can't contain interactive content, so the card's link must not sit inside one —
// otherwise activation is unreliable and the control tree assistive tech sees is broken.
expect(
await page.evaluate(
() =>
![...document.querySelectorAll('.tlui-cmt-stack-list__card a[href]')].some((a) =>
a.closest('button')
)
)
).toBe(true)
// The card is still openable by keyboard, through a named button covering its surface.
const action = card.locator('.tlui-cmt-stack-list__card-action')
await expect(action).toHaveAttribute('aria-label', /comment by/i)
await action.focus()
await page.keyboard.press('Enter')
await expect(page.locator('.tlui-cmt-stack-list__thread')).toHaveCount(1)
})
})

View File

@@ -0,0 +1,298 @@
import test, { Page, expect } from '@playwright/test'
import { hardResetEditor, setupPage, setupPageWithShapes, withMenu } from '../shared-e2e'
declare const __tldraw_ui_event: { name: string }
// `editor` is a global exposed on the page, not imported at test-runner level.
// Importing from 'tldraw' (even as `type`) breaks Node's --no-strip-types because
// the tldraw package re-exports PointerEvent from react, which doesn't exist in Node.
declare const editor: {
inputs: any
getCamera(): { x: number; y: number; z: number }
getCurrentToolId(): string
getPath(): string
getSelectedShapeIds(): string[]
}
// We're just testing the events, not the actual results.
let page: Page
// Mobile emulation in Playwright dispatches touch events for `mouse.click/down/up`,
// so it can't simulate a real right-mouse-button sequence. Tests that depend on
// right-click pointer events only run on desktop (Chromium).
const isMobileProject = () => test.info().project.name.includes('Mobile')
test.describe('Context menu', async () => {
test.beforeEach(async ({ browser }) => {
if (!page) {
page = await browser.newPage()
await setupPage(page)
} else {
await hardResetEditor(page)
}
await setupPageWithShapes(page)
})
test('distribute horizontal', async () => {
// distribute horizontal
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await withMenu(page, 'context-menu.arrange.distribute-horizontal', (item) => item.focus())
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'horizontal', source: 'context-menu' },
})
})
test('distribute vertical', async () => {
// distribute vertical — Shift+Alt+V
await page.keyboard.press('Control+a')
await page.mouse.click(200, 200, { button: 'right' })
await withMenu(page, 'context-menu.arrange.distribute-vertical', (item) => item.focus())
await page.keyboard.press('Enter')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'distribute-shapes',
data: { operation: 'vertical', source: 'context-menu' },
})
})
test('reopens after moving and right-clicking again', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
await page.mouse.move(600, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.up({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
expect(
await page.evaluate(() => ({
isRightPointing: editor.inputs.getIsRightPointing(),
isPanning: editor.inputs.getIsPanning(),
}))
).toMatchObject({
isRightPointing: false,
isPanning: false,
})
})
test('static right-click on plain canvas opens context menu', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Right-click on empty canvas (no menu already open)
await page.mouse.click(600, 400, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Wait for any async events (native contextmenu follows pointerup)
await page.waitForTimeout(100)
// Menu should still be visible — a leaked native contextmenu must not toggle it closed
await expect(page.getByTestId('context-menu')).toBeVisible()
expect(
await page.evaluate(() => ({
isRightPointing: editor.inputs.getIsRightPointing(),
isPanning: editor.inputs.getIsPanning(),
}))
).toMatchObject({
isRightPointing: false,
isPanning: false,
})
})
test('right-click drag on plain canvas pans without opening context menu', async () => {
const initialCamera = await page.evaluate(() => {
const camera = editor.getCamera()
return { x: camera.x, y: camera.y, z: camera.z }
})
await page.mouse.move(300, 300)
await page.mouse.down({ button: 'right' })
await page.mouse.move(500, 500, { steps: 5 })
expect(
await page.evaluate(() => ({
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: true,
isRightPointing: true,
})
await page.mouse.up({ button: 'right' })
// Context menu should NOT open after a drag
await expect(page.getByTestId('context-menu')).toBeHidden()
// Camera should have moved
expect(
await page.evaluate((initialCamera) => {
const camera = editor.getCamera()
return camera.x !== initialCamera.x || camera.y !== initialCamera.y
}, initialCamera)
).toBe(true)
// Input state should be clean
expect(
await page.evaluate(() => ({
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: false,
isRightPointing: false,
})
})
test('can begin a right-click drag after closing the menu', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
await page.mouse.move(600, 400)
const initialCamera = await page.evaluate(() => {
const camera = editor.getCamera()
return { x: camera.x, y: camera.y, z: camera.z }
})
await page.mouse.down({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeHidden()
await page.mouse.move(700, 500, { steps: 5 })
expect(
await page.evaluate(() => ({
camera: editor.getCamera(),
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
isPanning: true,
isRightPointing: true,
})
await page.mouse.up({ button: 'right' })
expect(
await page.evaluate((initialCamera) => {
const camera = editor.getCamera()
return {
cameraChanged:
camera.x !== initialCamera.x ||
camera.y !== initialCamera.y ||
camera.z !== initialCamera.z,
isPanning: editor.inputs.getIsPanning(),
isRightPointing: editor.inputs.getIsRightPointing(),
}
}, initialCamera)
).toMatchObject({
cameraChanged: true,
isPanning: false,
isRightPointing: false,
})
})
test('left-click closes menu and leaves editor idle', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Move to a clearly empty spot so the right-click doesn't select or hover a shape
await page.mouse.move(800, 600)
await page.mouse.click(800, 600, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Left-click elsewhere on empty canvas — should close the menu
await page.mouse.click(900, 700)
await expect(page.getByTestId('context-menu')).toBeHidden()
// Tool state clean, pointer released — regardless of selection state
expect(
await page.evaluate(() => ({
path: editor.getPath(),
isPointing: editor.inputs.getIsPointing(),
isRightPointing: editor.inputs.getIsRightPointing(),
}))
).toMatchObject({
path: 'select.idle',
isPointing: false,
isRightPointing: false,
})
})
test('left-click drag with menu open starts brush selection', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.move(800, 600)
await page.mouse.click(800, 600, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Left-click + drag entirely on empty canvas
await page.mouse.move(900, 100)
await page.mouse.down({ button: 'left' })
await page.mouse.move(1100, 300, { steps: 10 })
// Menu closes, editor enters brushing state
await expect(page.getByTestId('context-menu')).toBeHidden()
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
await page.mouse.up({ button: 'left' })
// After release: brush ended, editor idle, pointer released
expect(
await page.evaluate(() => ({
path: editor.getPath(),
isPointing: editor.inputs.getIsPointing(),
}))
).toMatchObject({
path: 'select.idle',
isPointing: false,
})
})
test('consecutive right-clicks keep menu continuously visible', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Right-click at a new location — observe visibility frequently during the transition
// to catch any hidden flash between the old menu closing and the new one opening.
await page.mouse.move(600, 400)
const menu = page.getByTestId('context-menu')
await page.mouse.down({ button: 'right' })
await page.mouse.up({ button: 'right' })
// Poll visibility for ~300ms; it must never be hidden during the transition.
const samples = await page.evaluate(async () => {
const results: boolean[] = []
const el = () => document.querySelector('[data-testid="context-menu"]')
for (let i = 0; i < 30; i++) {
results.push(!!el())
await new Promise((r) => setTimeout(r, 10))
}
return results
})
expect(samples.every((v) => v === true)).toBe(true)
await expect(menu).toBeVisible()
})
test('left-click drag works after a right-click drag with menu open', async () => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
// Open a menu
await page.mouse.click(200, 200, { button: 'right' })
await expect(page.getByTestId('context-menu')).toBeVisible()
// Right-click + drag to pan; this should release all pointer capture cleanly
await page.mouse.move(600, 400)
await page.mouse.down({ button: 'right' })
await page.mouse.move(700, 500, { steps: 5 })
await page.mouse.up({ button: 'right' })
await expect(page.getByTestId('context-menu')).toBeHidden()
// Now a regular left-click drag must still work — if pointer capture
// leaked to the capture div or canvas, this brush would not engage.
await page.mouse.move(100, 100)
await page.mouse.down({ button: 'left' })
await page.mouse.move(400, 400, { steps: 10 })
expect(await page.evaluate(() => editor.getPath())).toBe('select.brushing')
await page.mouse.up({ button: 'left' })
expect(await page.evaluate(() => editor.getPath())).toBe('select.idle')
})
})

View File

@@ -0,0 +1,134 @@
import { expect, type Page } from '@playwright/test'
import test from '../fixtures/fixtures'
const content = (page: Page) => page.locator('.tlui-dialog__content')
const selectContent = (page: Page) => page.getByTestId('dialog-select.content')
// Click the dialog backdrop at a point that is outside the centered content.
async function clickBackdrop(page: Page) {
const box = await content(page).boundingBox()
if (!box) throw new Error('Could not find dialog content')
// Midpoint of the left-hand backdrop, vertically aligned with the content.
await page.mouse.click(Math.max(5, box.x / 2), box.y + box.height / 2)
}
test.describe('dialogs', () => {
// These exercise backdrop/drag dismissal, which is desktop pointer behaviour.
test.skip(({ isMobile }) => isMobile, 'Dialog dismissal is tested with a desktop pointer')
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
test('a backdrop click dismisses a normal dialog', async ({ page }) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
await clickBackdrop(page)
await expect(content(page)).toHaveCount(0)
})
test('escape dismisses a dialog', async ({ page }) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
await page.keyboard.press('Escape')
await expect(content(page)).toHaveCount(0)
})
test('a press that starts inside the content and ends on the backdrop does not dismiss', async ({
page,
}) => {
await page.getByTestId('show-dialog').click()
await expect(content(page)).toBeVisible()
const box = await content(page).boundingBox()
if (!box) throw new Error('Could not find dialog content')
// Press down inside the content (e.g. selecting text)...
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
// ...drag out onto the backdrop and release.
await page.mouse.move(Math.max(5, box.x / 2), box.y + box.height / 2, { steps: 5 })
await page.mouse.up()
await expect(content(page)).toBeVisible()
})
test('an open select inside a modal is dismissed before the dialog', async ({ page }) => {
await page.getByTestId('show-dialog-with-select').click()
await expect(content(page)).toBeVisible()
// Open the select; its listbox portals into the container.
await page.getByTestId('dialog-select.trigger').click()
await expect(selectContent(page)).toBeVisible()
// The first outside click closes only the select; the dialog stays open.
await clickBackdrop(page)
await expect(selectContent(page)).toHaveCount(0)
await expect(content(page)).toBeVisible()
// A second outside click then closes the dialog.
await clickBackdrop(page)
await expect(content(page)).toHaveCount(0)
})
})
test.describe('stacked dialogs', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
// Stacked modal dialogs must stay interactive — including the topmost dialog's controls
// on a touchscreen. Making lower stacked dialogs non-modal previously left the nested
// dialog unresponsive to taps on mobile, so this runs on mobile as well as desktop.
test('a nested dialog stacks over its parent and stays interactive', async ({
page,
isMobile,
}) => {
const act = (testId: string) =>
isMobile ? page.getByTestId(testId).tap() : page.getByTestId(testId).click()
await act('show-nested-dialog')
await expect(page.getByTestId('dialog-parent')).toBeVisible()
await act('dialog-parent.open-nested')
await expect(page.getByTestId('dialog-nested')).toBeVisible()
// Opening the nested dialog must not collapse the parent.
await expect(page.getByTestId('dialog-parent')).toBeVisible()
// The topmost dialog's control must respond (taps included): closing it leaves the
// parent open underneath.
await act('dialog-nested.confirm')
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
await expect(page.getByTestId('dialog-parent')).toBeVisible()
})
})
// A touch press is checked against the dialog stack at the click that follows it, not at the
// press, so this needs a genuine touch-to-click sequence to reproduce. The viewport is wide on
// purpose: the editor cancels a touchstart close to the window's edges, which substitutes a
// synthesised click and moves the check earlier than the real one, hiding the bug.
test.describe('stacked dialogs on a wide touch viewport', () => {
test.use({ viewport: { width: 1024, height: 768 }, hasTouch: true, isMobile: true })
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:5420/toasts-and-dialogs')
await page.waitForSelector('.tl-canvas')
})
test('confirming the nested dialog by touch leaves the parent open', async ({ page }) => {
await page.getByTestId('show-nested-dialog').tap()
await expect(page.getByTestId('dialog-parent')).toBeVisible()
await page.getByTestId('dialog-parent.open-nested').tap()
await expect(page.getByTestId('dialog-nested')).toBeVisible()
// By the time the deferred check runs the nested dialog has unmounted, leaving the parent
// topmost — the press that closed the nested dialog must not read as a press outside it.
await page.getByTestId('dialog-nested.confirm').tap()
await expect(page.getByTestId('dialog-nested')).toHaveCount(0)
await expect(page.getByTestId('dialog-parent')).toBeVisible()
})
})

View File

@@ -0,0 +1,298 @@
import test, { expect } from '@playwright/test'
import { Editor } from 'tldraw'
declare const editor: Editor
declare const EDITOR_A: Editor
declare const EDITOR_B: Editor
declare const EDITOR_C: Editor
// We're just testing the events, not the actual results.
test.describe('Focus', () => {
test('focus events', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
const EditorC = (await page.$(`.C`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
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 (
!(
EDITOR_A.getInstanceState().isFocused === (id === 'A') &&
EDITOR_B.getInstanceState().isFocused === (id === 'B') &&
EDITOR_C.getInstanceState().isFocused === (id === 'C')
)
) {
throw Error('isFocused is not correct')
}
},
{ id }
)
}
// Component A has autofocus
// Component B does not
// Component C does not
// Component B and C share persistence id
await (await page.$('body'))?.click()
await isOnlyFocused('A')
await EditorA.click()
await isOnlyFocused('A')
await EditorA.click()
await isOnlyFocused('A')
await EditorB.click()
await isOnlyFocused('B')
// Escape does not break focus
await page.keyboard.press('Escape')
await isOnlyFocused('B')
})
test('kbds when not focused', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
// Should not have any shapes on the page
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(0)
const EditorA = (await page.$(`.A`))!
await page.keyboard.press('r')
await EditorA.click({ position: { x: 100, y: 100 } })
// Should not have created a shape
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
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
expect(await page.evaluate(() => EDITOR_A.getCurrentPageShapes().length)).toBe(1)
})
test('kbds when focused', async ({ page }) => {
await page.goto('http://localhost:5420/multiple/full')
await page.waitForSelector('.tl-canvas')
const EditorA = (await page.$(`.A`))!
const EditorB = (await page.$(`.B`))!
const EditorC = (await page.$(`.C`))!
expect(EditorA).toBeTruthy()
expect(EditorB).toBeTruthy()
expect(EditorC).toBeTruthy()
await (await page.$('body'))?.click()
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await page.keyboard.press('d')
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await EditorB.click()
await page.waitForTimeout(100) // takes 30ms or so to focus
await page.keyboard.press('d')
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
expect(await EditorB.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
})
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
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
null
)
await drawButton?.click()
// draw button should be selected now
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).not.toBe(
null
)
await page.keyboard.press('v')
// select button should be selected again
expect(await EditorA.$('.tlui-button[data-testid="tools.draw"][aria-pressed="true"]')).toBe(
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?.click()
// Text should still be focused.
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
).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')
const firstNoteId = await page.evaluate(() => editor.getEditingShapeId())
const firstNote = page.locator(`.tl-shape[data-shape-id="${firstNoteId}"]`)
// create new note next to it
await page.keyboard.press('Tab')
await expect(page.locator('.tl-shape')).toHaveCount(2)
// Tab pans to the new note; let the camera settle so the first note is where
// the locator resolves it before clicking.
await page.waitForFunction(() => editor.getCameraState() === 'idle')
await firstNote.click()
await page.waitForTimeout(1000)
// The first note, not the one Tab created, should be the focused one.
await expect(firstNote.locator('div[contenteditable]')).toBeFocused()
})
test('exits edit mode when dragging from text label with blurred input', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// Create a geo shape with text
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.mouse.click(400, 400)
// Double-click to edit the shape
await page.mouse.dblclick(300, 300)
await page.waitForTimeout(100)
// Verify we're in edit mode
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
).toBe(true)
// Click near the text and drag (this should blur the input and exit edit mode)
await page.mouse.move(300, 300)
await page.mouse.down()
await page.mouse.move(350, 350)
await page.waitForTimeout(100)
// Verify we've exited edit mode (no focused contenteditable)
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape [contenteditable]:focus'))
).toBe(false)
await page.mouse.up()
})
test('selects all text when clicking on blurred text input in editing mode', async ({ page }) => {
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// Create geo shape and enter edit mode
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.mouse.click(400, 400)
await page.mouse.dblclick(300, 300)
// Wait for contenteditable to be focused before typing
await page.waitForFunction(
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
)
// Type text
await page.keyboard.type('test', { delay: 10 })
// Verify initial text
const contenteditable = page.locator('.tl-shape [contenteditable]')
await expect(contenteditable).toHaveText('test')
// Blur input while staying in editing mode by clicking on the text label (but not the input itself)
await page.mouse.click(200, 230)
await page.waitForTimeout(100)
await page.waitForFunction(() => document.getSelection()?.toString() === 'test')
expect(
await page.evaluate(() => !!document.querySelector('.tl-shape div[contenteditable]:focus'))
).toBe(true)
})
})

View File

@@ -0,0 +1,248 @@
import { expect } from '@playwright/test'
import { Editor, TLAssetId, TLImageShape } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
declare const editor: Editor
test.describe('Image toolbar behaviour', () => {
test.beforeEach(setupOrReset)
test.beforeEach(async ({ page, isMobile }) => {
// TODO: the mobile e2e test may need special handling
if (isMobile) return
// Create an image shape by inserting an image
await page.evaluate(() => {
const imageAsBase64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const assetId = 'asset:img' as TLAssetId
editor.createAssets([
{
id: assetId,
typeName: 'asset',
type: 'image',
props: {
src: imageAsBase64,
w: 100,
h: 100,
name: 'test',
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
},
])
editor.createShape({
type: 'image',
x: 250,
y: 250,
props: {
w: 200,
h: 150,
crop: {
topLeft: { x: 0, y: 0 },
bottomRight: { x: 1, y: 1 },
},
assetId,
},
})
})
// Select the image
await page.mouse.click(300, 300)
// Wait for the toolbar to animate in
await page.waitForTimeout(150)
})
test('zoom slider changes image crop', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Find and use the slider
const slider = page.getByTestId('tool.image-zoom')
await slider.click()
// Move to a higher zoom
await page.mouse.move(
await slider.boundingBox().then((box) => box!.x + 100),
await slider.boundingBox().then((box) => box!.y + 20)
)
await page.mouse.down()
await page.mouse.up()
const crop = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.crop
})
expect(crop!.bottomRight.x).toBeCloseTo(0.6732, 2)
expect(crop!.bottomRight.y).toBeCloseTo(0.6732, 2)
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
expect(crop!.topLeft.y).toBeCloseTo(0.3267, 2)
})
test('aspect ratio dropdown changes image crop', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Open aspect ratio dropdown
await page.getByTestId('tool.image-aspect-ratio').click()
// Select the square option
await page.getByText('Square').click()
// Check that the image has been cropped to square
const isSquare = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const aspectRatio = imageShape.props.w / imageShape.props.h
return Math.abs(aspectRatio - 1) < 0.01 // Check if width/height ratio is approximately 1
})
expect(isSquare).toBe(true)
// Try another aspect ratio - landscape
await page.getByText('Landscape').click()
// Check that the image has been cropped to landscape (4:3)
const isLandscape = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const aspectRatio = imageShape.props.w / imageShape.props.h
return Math.abs(aspectRatio - 4 / 3) < 0.01 // Check if width/height ratio is approximately 4/3
})
expect(isLandscape).toBe(true)
})
test.skip('flip buttons flip the image', async ({ page, isMobile }) => {
if (isMobile) return
const manipulator = page.getByTestId('tool.image-crop')
await manipulator.click()
// Get initial state
const initialState = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
// Click flip horizontal button
await page.getByTestId('tool.image-flip-horizontal').click()
// Check horizontal flip state
const afterHorizontalFlip = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
expect(afterHorizontalFlip.flipX).not.toEqual(initialState.flipX)
expect(afterHorizontalFlip.w).toEqual(initialState.w) // Width shouldn't change
// Click flip vertical button
await page.getByTestId('tool.image-flip-vertical').click()
// Check vertical flip state
const afterVerticalFlip = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return {
w: imageShape.props.w,
h: imageShape.props.h,
flipX: imageShape.props.flipX ?? false,
flipY: imageShape.props.flipY ?? false,
}
})
expect(afterVerticalFlip.flipY).not.toEqual(initialState.flipY)
expect(afterVerticalFlip.h).toEqual(initialState.h) // Height shouldn't change
})
test('alt text button opens alt text editor', async ({ page, isMobile }) => {
if (isMobile) return
// Click the Alt Text button
await page.getByTestId('tool.image-alt-text').click()
// Enter some alt text
await page.getByTestId('media-toolbar.alt-text-input').fill('A test image for e2e testing')
// Save the alt text
await page.getByTestId('tool.media-alt-text-confirm').click()
// Check that the alt text was saved
const altText = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.altText
})
expect(altText).toBe('A test image for e2e testing')
})
test('image replacement works', async ({ page, isMobile }) => {
if (isMobile) return
// Get initial image asset id
const initialAssetId = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.assetId
})
// Click the replace image button
await page.getByTestId('tool.image-replace').click()
// This would typically open a file dialog, which is hard to test in Playwright
// Instead, we'll directly trigger the image replacement through evaluation
await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
const newImageAsBase64 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEklEQVR42mNk+M9QzwAEjDAGACCDAv8cI7IoAAAAAElFTkSuQmCC'
const newAssetId = 'asset:img-new' as TLAssetId
editor.createAssets([
{
id: newAssetId,
typeName: 'asset',
type: 'image',
props: {
src: newImageAsBase64,
w: 100,
h: 100,
name: 'test',
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
},
])
editor.updateShape({
id: imageShape.id,
type: imageShape.type,
props: {
assetId: newAssetId,
},
})
})
// Check that asset id changed
const newAssetId = await page.evaluate(() => {
const imageShape = editor.getSelectedShapes()[0] as TLImageShape
return imageShape.props.assetId
})
expect(newAssetId).not.toEqual(initialAssetId)
})
})

View File

@@ -0,0 +1,891 @@
import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupPage, setupPageWithShapes } from '../shared-e2e'
declare const editor: Editor
declare const __tldraw_ui_event: { name: string }
// We're just testing the events, not the actual results.
let page: Page
test.describe('Keyboard Shortcuts', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('tools', async ({ isMobile }) => {
const geoToolKds = [
['r', 'rectangle'],
['o', 'ellipse'],
]
for (const [key, geo] of geoToolKds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: `geo-${geo}`, source: 'kbd' },
})
}
const simpleToolKbds = [
['v', 'select'],
['h', 'hand'],
]
for (const [key, tool] of simpleToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'kbd' },
})
}
const shapeToolKbds = [
['d', 'draw'],
['x', 'draw'],
['a', 'arrow'],
['l', 'line'],
['f', 'frame'],
['n', 'note'],
['f', 'frame'],
['e', 'eraser'],
['k', 'laser'],
['t', 'text'],
]
for (const [key, tool] of shapeToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'kbd' },
})
}
// make sure that the first dropdown item is rectangle
await page.keyboard.press('r')
const positionalToolKbds = [
['1', 'select'],
['2', 'hand'],
['3', 'draw'],
['4', 'eraser'],
['5', 'arrow'],
['6', 'text'],
]
if (isMobile) {
// on mobile, the last item (first from the dropdown) is 6
positionalToolKbds.push(['7', 'geo-rectangle'])
} else {
// on desktop, the last item (first from the dropdown) is 9
positionalToolKbds.push(['9', 'geo-rectangle'])
}
for (const [key, tool] of positionalToolKbds) {
await page.keyboard.press('v') // set back to select
await page.keyboard.press(key)
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-tool',
data: { id: tool, source: 'toolbar' },
})
}
})
})
test.describe('Keyboard Shortcuts', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
// Make some shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
})
test('Zoom in', async () => {
await page.keyboard.press('Control+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-in',
data: { source: 'kbd' },
})
})
test('Zoom out', async () => {
await page.keyboard.press('Control+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-out',
data: { source: 'kbd' },
})
})
test('Zoom to fit', async () => {
await page.keyboard.press('Shift+1')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-to-fit',
data: { source: 'kbd' },
})
})
test('Zoom to selection', async () => {
await page.keyboard.press('Shift+2')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'zoom-to-selection',
data: { source: 'kbd' },
})
})
test('Zoom to 100', async () => {
await page.keyboard.press('Shift+0')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reset-zoom',
data: { source: 'kbd' },
})
})
/* ---------------------- Files --------------------- */
// new-project — Cmd+N
// open — Cmd+O
// save — Cmd+S
// save-as — Cmd+Shift+S
// upload-media — Cmd+I
/* -------------------- Clipboard ------------------- */
// await page.keyboard.press('Control+c')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'copy',
// data: { source: 'kbd' },
// })
// await page.keyboard.press('Control+v')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'paste',
// data: { source: 'kbd' },
// })
// await page.keyboard.press('Control+x')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'cut',
// data: { source: 'kbd' },
// })
test('Toggle grid mode', async () => {
await page.keyboard.press("Control+'")
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-grid-mode',
data: { source: 'kbd' },
})
})
test('Toggle dark mode', async () => {
await page.keyboard.press('Control+/')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'color-scheme',
data: { source: 'kbd' },
})
})
test('Toggle tool lock', async () => {
await page.keyboard.press('q')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-tool-lock',
data: { source: 'kbd' },
})
})
test('Copy hovered styles', async () => {
// The action copies styles from the shape under the pointer, so create a shape in the
// middle of the viewport and move the mouse over it before pressing the shortcut.
const point = await page.evaluate(() => {
const center = editor.getViewportPageBounds().center
editor.createShape({
type: 'geo',
x: center.x - 50,
y: center.y - 50,
props: { w: 100, h: 100, fill: 'solid' },
})
const screen = editor.pageToScreen(center)
return { x: screen.x, y: screen.y }
})
await page.mouse.move(point.x, point.y)
await page.keyboard.press('Shift+q')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'copy-hovered-styles',
data: { source: 'kbd' },
})
})
})
test.describe('Actions on shapes', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
/* -------------- Operations on Shapes -------------- */
test('Operations on shapes', async () => {
await setupPageWithShapes(page)
// needs shapes on the canvas
// select-all — Cmd+A
await page.keyboard.press('Control+a')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'select-all-shapes',
data: { source: 'kbd' },
})
// flip-h — Shift+H
await page.keyboard.press('Shift+h')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'flip-shapes',
data: { operation: 'horizontal', source: 'kbd' },
})
// flip-v — Shift+V
await page.keyboard.press('Shift+v')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'flip-shapes',
data: { operation: 'vertical', source: 'kbd' },
})
// move-to-front — ]
await page.keyboard.press(']')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'toFront', source: 'kbd' },
})
// move-forward — Alt+]
await page.keyboard.press('Alt+]')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'forward', source: 'kbd' },
})
// move-to-back — [
await page.keyboard.press('[')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'toBack', source: 'kbd' },
})
// move-backward — Alt+[
await page.keyboard.press('Alt+[')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'reorder-shapes',
data: { operation: 'backward', source: 'kbd' },
})
// group — Cmd+G
await page.keyboard.press('Control+g')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'group-shapes',
data: { source: 'kbd' },
})
// ungroup — Cmd+Shift+G
await page.keyboard.press('Control+Shift+g')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'ungroup-shapes',
data: { source: 'kbd' },
})
// duplicate — Cmd+D
await page.keyboard.press('Control+d')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'duplicate-shapes',
data: { source: 'kbd' },
})
// align left — Alt+A
await page.keyboard.press('Alt+a')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'left', source: 'kbd' },
})
// align right — Alt+D
await page.keyboard.press('Alt+d')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'right', source: 'kbd' },
})
// align top — Alt+W
await page.keyboard.press('Alt+w')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'top', source: 'kbd' },
})
// align bottom — Alt+W'
await page.keyboard.press('Alt+s')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'align-shapes',
data: { operation: 'bottom', source: 'kbd' },
})
// Copy as SVG — this should have a clipboard error
// await page.keyboard.press('Control+Shift+c')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'copy-as',
// data: { format: 'svg', source: 'kbd' },
// })
// delete — backspace
await page.keyboard.press('Control+a') // selected
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// delete — ⌫
// Make some shapes and select them
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 250)
await page.keyboard.press('v')
await page.keyboard.press('Control+a')
await page.keyboard.press('Delete')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// Next, previous pages — Alt+ArrowLeft, Alt+ArrowRight
// Try a previous page move. We can't go lower since we're on the first page.
// So the most recent event should be the previous delete
await page.keyboard.press('Alt+ArrowLeft')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// Next page. Since there's only one page and the page is empty, nothing should happen.
await page.keyboard.press('Alt+ArrowRight')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
// make something on the new page and delete it
await page.keyboard.press('r')
await page.mouse.click(100, 100)
// If there's something on the page, we can create the next page by moving up
await page.keyboard.press('Alt+ArrowRight')
// We'll also have a change page here... but the most recent will be the new page
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'new-page',
data: { source: 'kbd' },
})
// We can go back down...
await page.keyboard.press('Alt+ArrowLeft')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'prev' },
})
// We can go up again
await page.keyboard.press('Alt+ArrowRight')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'next' },
})
// We can back down and up with the up and down keys too...
await page.keyboard.press('Alt+ArrowUp')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'prev' },
})
await page.keyboard.press('Alt+ArrowDown')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'change-page',
data: { source: 'kbd', direction: 'next' },
})
/* ---------------------- Misc ---------------------- */
// These actions require selected shapes to run
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(100, 300)
await page.keyboard.press('r')
await page.mouse.click(300, 300)
// First verify they don't fire when nothing is selected
await page.keyboard.press('Escape') // deselect all
await page.keyboard.press('Escape') // deselect all
const prevEvent = await page.evaluate(() => __tldraw_ui_event)
await page.keyboard.press('Shift+l')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
await page.keyboard.press('Control+Shift+Alt+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
await page.keyboard.press('Control+Shift+Alt+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject(prevEvent)
// Now select shapes and verify the actions fire
await page.keyboard.press('Control+a')
await page.keyboard.press('Control+Shift+Alt+=')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'enlarge-shapes',
})
await page.keyboard.press('Control+Shift+Alt+-')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'shrink-shapes',
})
// toggle lock (must be the last one here)
await page.keyboard.press('Shift+l')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'toggle-lock',
})
// await page.keyboard.press('Control+i')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'open-menu',
// data: { source: 'dialog' },
// })
// await page.keyboard.press('Control+u')
// expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
// name: 'open-menu',
// data: { source: 'dialog' },
// })
})
// TODO: Annoyingly, the alt key is being 'released' in the above test suite.
// I'll need to figure that out in a separate PR. In the meantime,
// this pulls out the rotate tests into a separate test suite.
test('rotate', async () => {
await setupPageWithShapes(page)
await page.keyboard.press('Control+a')
// rotate — Shift+ArrowLeft, Shift+ArrowRight
await page.keyboard.press('Shift+.')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-cw',
data: { source: 'kbd', fine: false },
})
await page.keyboard.press('Shift+,')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-ccw',
data: { source: 'kbd', fine: false },
})
// rotate — Shift+Alt+ArrowLeft, Shift+Alt+ArrowRight
await page.keyboard.press('Shift+Alt+.')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-cw',
data: { source: 'kbd', fine: true },
})
await page.keyboard.press('Shift+Alt+,')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'rotate-ccw',
data: { source: 'kbd', fine: true },
})
})
})
test.describe('Delete bug', () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('delete bug without drag', async () => {
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
})
test('delete bug with drag', async () => {
await page.keyboard.press('r')
await page.mouse.down()
await page.mouse.move(100, 100)
await page.mouse.up()
await page.keyboard.press('Backspace')
expect(await page.evaluate(() => __tldraw_ui_event)).toMatchObject({
name: 'delete-shapes',
data: { source: 'kbd' },
})
})
})
test.describe('Shape Navigation', () => {
test.beforeEach(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('Tab navigation between shapes', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create multiple shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 100)
await page.keyboard.press('r')
await page.mouse.click(400, 100)
await page.keyboard.press('v')
// Click on the first shape to select it
await page.mouse.click(100, 100)
// Navigate forward with Tab
await page.keyboard.press('Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 150,
y: 0,
})
// Navigate backward with Shift+Tab
await page.keyboard.press('Shift+Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 0,
y: 0,
})
})
test('Directional navigation between shapes', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create shapes in different directions
await page.keyboard.press('r')
await page.mouse.click(200, 200) // Center shape
await page.keyboard.press('r')
await page.mouse.click(300, 200) // Right shape
await page.keyboard.press('r')
await page.mouse.click(100, 200) // Left shape
await page.keyboard.press('r')
await page.mouse.click(200, 100) // Up shape
await page.keyboard.press('r')
await page.mouse.click(200, 300) // Down shape
await page.keyboard.press('v')
// Select center shape
await page.mouse.click(200, 200)
// Test navigation to the right
await page.keyboard.press('Control+ArrowRight')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 200,
y: 100,
})
// Navigate back to center
await page.mouse.click(200, 200)
// Disabled: I think this conflicts with browser nav or something.
// // Test navigation to the left
// await page.keyboard.press('Control+ArrowLeft')
// expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
// x: 200,
// y: 100
// })
// Navigate back to center
await page.mouse.click(200, 200)
// Test navigation up
await page.keyboard.press('Control+ArrowUp')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 100,
y: 100,
})
// Navigate back to center
await page.mouse.click(200, 200)
// Test navigation down
await page.keyboard.press('Control+ArrowDown')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 100,
y: 200,
})
})
test('Container navigation (entering and exiting containers)', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create a frame
await page.keyboard.press('f')
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create a shape inside the frame
await page.keyboard.press('r')
await page.mouse.click(200, 200)
await page.keyboard.press('v')
// Navigate up to the parent (frame) with Control+Shift+Up
await page.keyboard.press('Control+Shift+ArrowUp')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'frame'
})
).toBe(true)
// Navigate down to the first child with Control+Shift+Down
await page.keyboard.press('Control+Shift+ArrowDown')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'geo'
})
).toBe(true)
// Now test with a group
// Create multiple shapes to group
await page.keyboard.press('v')
await page.keyboard.press('r')
await page.mouse.click(450, 200)
await page.keyboard.press('r')
await page.mouse.click(475, 200)
// Select both shapes
await page.keyboard.press('v')
await page.mouse.move(310, 50)
await page.mouse.down()
await page.mouse.move(310, 50)
await page.mouse.move(700, 500)
await page.mouse.up()
// Group them
await page.keyboard.press('Control+g')
// Navigate up to the group with Control+Shift+Up
await page.keyboard.press('Control+Shift+ArrowUp')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'group'
})
).toBe(true)
// Navigate down to a child in the group
await page.keyboard.press('Control+Shift+ArrowDown')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()
return shape && shape.type === 'geo'
})
).toBe(true)
})
test('Tab navigation respects container boundaries', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Clear the canvas and reset the view
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
await page.keyboard.press('v')
// Create a frame
await page.keyboard.press('f')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create shapes inside the frame
await page.keyboard.press('r')
await page.mouse.click(150, 150) // Shape inside frame
await page.keyboard.press('r')
await page.mouse.click(250, 150) // Another shape inside frame
// Create shapes outside the frame
await page.keyboard.press('r')
await page.mouse.click(400, 150) // Shape outside frame
await page.keyboard.press('r')
await page.mouse.click(400, 250) // Another shape outside frame
await page.keyboard.press('v')
// Test 1: Tab from outside shape should only navigate between outside shapes
await page.mouse.click(400, 150) // Select first outside shape
// Tab to next outside shape
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 100,
y: 100,
})
// Tab again should wrap around to first outside shape
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 300,
y: 50,
})
// Test 2: Tab from inside shape should only navigate between shapes in the same container
await page.mouse.click(150, 150) // Select first inside shape
// Tab to next shape inside frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: -50,
y: -50,
})
// Tab again should wrap around to first shape inside frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
return { x: Math.round(shape.x), y: Math.round(shape.y) }
})
).toMatchObject({
x: 50,
y: -50,
})
})
test('Tab navigation with nested containers', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Clear the canvas and reset the view
await page.keyboard.press('Control+a')
await page.keyboard.press('Backspace')
await page.keyboard.press('v')
// Create an outer frame
await page.keyboard.press('f')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(500, 400)
await page.mouse.up()
// Create an inner frame inside the outer frame
await page.keyboard.press('f')
await page.mouse.move(150, 150)
await page.mouse.down()
await page.mouse.move(300, 300)
await page.mouse.up()
// Create shapes directly inside outer frame
await page.keyboard.press('r')
await page.mouse.click(400, 200) // Shape in outer frame
await page.keyboard.press('r')
await page.mouse.click(400, 300) // Another shape in outer frame
// Create shapes inside inner frame
await page.keyboard.press('r')
await page.mouse.click(200, 200) // Shape in inner frame
await page.keyboard.press('r')
await page.mouse.click(250, 200) // Another shape in inner frame
await page.keyboard.press('v')
// Test: Tab from inner frame shape should only navigate within inner frame
await page.mouse.click(200, 200) // Select first shape in inner frame
// Tab to next shape in inner frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const parent = editor.getShape(shape.parentId)
return {
shapeX: Math.round(shape.x),
shapeY: Math.round(shape.y),
isInInnerFrame:
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
}
})
).toMatchObject({
shapeX: 0,
shapeY: -50,
isInInnerFrame: true,
})
// Tab should never select shapes from outer frame
await page.keyboard.press('Tab')
expect(
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const parent = editor.getShape(shape.parentId)
return {
isInInnerFrame:
parent && parent.type === 'frame' && parent.parentId !== editor.getCurrentPageId(),
}
})
).toMatchObject({
isInInnerFrame: true,
})
})
test('Tab navigation does not happen when alt key is pressed', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
// Create multiple shapes
await page.keyboard.press('r')
await page.mouse.click(100, 100)
await page.keyboard.press('r')
await page.mouse.click(250, 100)
await page.keyboard.press('r')
await page.mouse.click(400, 100)
await page.keyboard.press('v')
// Click on the first shape to select it
await page.mouse.click(100, 100)
await page.keyboard.press('Alt+Tab')
expect(await page.evaluate(() => editor.getOnlySelectedShape())).toMatchObject({
x: 0,
y: 0,
})
})
})

View File

@@ -0,0 +1,182 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
// Helper function to set up license testing
async function setupLicenseTest(page: any, licenseKey: string) {
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = "${licenseKey}";
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForTimeout(6000) // Wait for license processing
}
test.describe('Internal license', () => {
test('does not render editor for expired internal license', async ({ page }) => {
const expiredInternalLicenseKey =
'tldraw-/WyJ0ZXN0LWludGVybmFsLWV4cGlyZWQtNm1vbnRocyIsWyJsb2NhbGhvc3QiXSw1LCIyMDI1LTAzLTAyVDA5OjM5OjMxLjYzMVoiXQ==.efmj2rGm9PqHqW4HqYE420Nomdubcm693r2gr+WWaIRQoA+I5BNJ2Z2kan0SdbXKcWj4oeyNruSeo7ixtRmfbg=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredInternalLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
// The actual editor canvas should not be rendered
await expect(page.locator('.tl-canvas')).not.toBeVisible()
// Check that console contains license expiry message
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw license has been expired for more than 30 days!')
)
expect(hasExpiredMessage).toBe(true)
})
})
test.describe('Watermarked license', () => {
test('shows watermark with default license key', async ({ page }) => {
// Don't set any license key - this should use our default license that shows the watermark
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// The editor should render normally
await expect(page.locator('.tl-canvas')).toBeVisible()
// The watermark should be visible (default license has WITH_WATERMARK flag)
await expect(page.getByTestId('tl-watermark-licensed')).toBeVisible()
// The unlicensed watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-unlicensed')).not.toBeVisible()
})
})
test.describe('Unlicensed', () => {
test('shows unlicensed watermark when no license key provided', async ({ page }) => {
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
// Explicitly set no license key by setting it to null
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = null;
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
// Debug: Check what license key is being used (should be null)
const licenseKeyUsed = await page.evaluate(() => {
return (window as any).__TLDRAW_LICENSE_KEY__
})
expect(licenseKeyUsed).toBe(null)
})
test('shows unlicensed watermark when license key is undefined', async ({ page }) => {
// Explicitly set license key to undefined
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = undefined;
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
})
test('shows unlicensed watermark when license key is empty string', async ({ page }) => {
// Set license key to empty string
await page.addInitScript(`
window.__TLDRAW_LICENSE_KEY__ = "";
`)
await page.goto('http://localhost:5420/end-to-end')
await page.waitForSelector('.tl-canvas')
// In development mode (localhost), the editor should render normally with watermark
await expect(page.locator('.tl-canvas')).toBeVisible()
// The unlicensed watermark should be visible since we have no valid license
await expect(page.getByTestId('tl-watermark-unlicensed')).toBeVisible()
// The watermark should NOT be visible
await expect(page.getByTestId('tl-watermark-licensed')).not.toBeVisible()
// License gate should not be visible (editor is not blocked)
await expect(page.getByTestId('tl-license-expired')).not.toBeVisible()
})
})
test.describe('Expired evaluation license', () => {
test('does not render editor for expired evaluation license', async ({ page }) => {
const expiredEvaluationLicenseKey =
'tldraw-/WyJ0ZXN0LWV2YWwtZXhwaXJlZC03ZGF5cyIsWyJsb2NhbGhvc3QiXSwxNiwiMjAyNS0wOC0yMlQwODoyMDoyMC43MjJaIl0=.XIuM3PlrSjs3WMX2tWzgZil/x/TNliCR/NgHtUHcr6nzk2n6S+2ijsd6w+MPP9uEyUdRdg/FQLfcYJ5pqA/G0w=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredEvaluationLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
await expect(page.locator('.tl-canvas')).not.toBeVisible()
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw evaluation license has expired!')
)
expect(hasExpiredMessage).toBe(true)
})
})
test.describe('Expired annual/perpetual license', () => {
test('does not render editor for expired annual license (beyond grace period)', async ({
page,
}) => {
const expiredAnnualLicenseKey =
'tldraw-/WyJ0ZXN0LWV4cGlyZWQtNzAiLFsibG9jYWxob3N0Il0sMSwiMjAyNS0wNi0yMFQwODoxODoyMS4zODRaIl0=.99Ce05q5rnBGhpTzptrTtvmYGjD4EEmWRbcrYqcVFdR1cRbcCzmHNSoGIz1wFWjpZSNQrMZ2ezj5yPALFRjVEw=='
const consoleMessages: string[] = []
page.on('console', (msg) => {
consoleMessages.push(msg.text())
})
await setupLicenseTest(page, expiredAnnualLicenseKey)
await expect(page.getByTestId('tl-license-expired')).toBeAttached()
await expect(page.locator('.tl-canvas')).not.toBeVisible()
const hasExpiredMessage = consoleMessages.some((msg) =>
msg.includes('Your tldraw license has been expired for more than 30 days!')
)
expect(hasExpiredMessage).toBe(true)
const hasAnnualMessage = consoleMessages.some((msg) =>
msg.includes('Please reach out to sales@tldraw.com to renew your license.')
)
expect(hasAnnualMessage).toBe(true)
})
})

View File

@@ -0,0 +1,44 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('help menu', () => {
test.beforeEach(setupOrReset)
test('you can open and close the menu', async ({ mainMenu, page }) => {
const { mainMenuButton, buttons } = mainMenu
const { editSubmenu, viewSubmenu, preferencesSubmenu } = mainMenu.subMenus
const submenus = [
{ name: 'preferences', submenu: preferencesSubmenu, button: buttons.preferences },
{ name: 'view', submenu: viewSubmenu, button: buttons.view },
{ name: 'edit', submenu: editSubmenu, button: buttons.edit },
]
await test.step('open main menu', async () => {
await expect(buttons.edit).toBeHidden()
await mainMenuButton.click()
await expect(buttons.edit).toBeVisible()
})
for (const submenu of submenus) {
await test.step(`hovering opens ${submenu.name} submenu`, async () => {
await expect(submenu.submenu[0]).toBeHidden()
await submenu.button.hover()
await expect(submenu.submenu[0]).toBeVisible()
})
}
await test.step('close main menu', async () => {
// close the menu by clicking the main menu button again
await mainMenuButton.click()
await expect(buttons.edit).toBeHidden()
// open the menu again
await mainMenuButton.click()
// click somewhere on the canvas to close the menu
await page.mouse.click(250, 150)
await expect(buttons.edit).toBeHidden()
})
})
// ...
// More tests here
// ...
})

View File

@@ -0,0 +1,33 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('navigationPanel', () => {
test.beforeEach(setupOrReset)
test('you can open and close the zoom menu', async ({ navigationPanel, isMobile }) => {
// no navigationPanel on mobile
test.skip(isMobile)
const { zoomMenuButton } = navigationPanel
const { zoomIn } = navigationPanel.zoomMenuItems
await expect(zoomIn).toBeHidden()
await zoomMenuButton.click()
await expect(zoomIn).toBeVisible()
await zoomMenuButton.click()
await expect(zoomIn).toBeHidden()
})
test('you can toggle the minimap', async ({ navigationPanel, isMobile }) => {
// no navigationPanel on mobile
test.skip(isMobile)
const { minimap, toggleButton } = navigationPanel
await expect(minimap).toBeHidden()
await toggleButton.click()
await expect(minimap).toBeVisible()
await toggleButton.click()
await expect(minimap).toBeHidden()
})
// ...
// More tests here
// ...
})

View File

@@ -0,0 +1,261 @@
import { Page, expect } from '@playwright/test'
import { Editor, IndexKey } from 'tldraw'
import test from '../fixtures/fixtures'
import { hardResetEditor, setup } from '../shared-e2e'
declare const editor: Editor
// Screenshot-based regression tests for canvas overlay appearance.
//
// Overlays render to a single <canvas class="tl-canvas-overlays"> element, so
// we screenshot the `.tl-canvas` region and let the image compare cover every
// overlay kind (selection foreground, shape handles, brush, snap indicators,
// scribble, etc.) at once.
//
// Tests use direct editor state injection (brush/zoomBrush via
// updateInstanceState, scribbles via ScribbleManager, snap indicators via
// SnapManager.setIndicators) rather than simulating drags. This is what the
// unit tests for each OverlayUtil already do, and it keeps the states frame-
// exact rather than timing-dependent.
async function prepare(page: Page) {
await page.evaluate(() => {
editor.user.updateUserPreferences({ colorScheme: 'light', animationSpeed: 0 })
editor.setCamera({ x: 0, y: 0, z: 1 }, { immediate: true })
editor.updateInstanceState({ isGridMode: false })
})
// Move mouse out of any shape to avoid hover indicators.
await page.mouse.move(0, 0)
}
async function snapshotCanvas(page: Page) {
// Wait for two rAFs so reactive overlay canvases have committed their paint.
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
)
)
// Allow a small tolerance to absorb sub-pixel anti-aliasing variance on
// rotated or curved overlay strokes. A real overlay regression (missing
// handle, wrong color, moved stroke) changes far more than 0.5% of pixels.
// Screenshot capture also waits for the canvas element to become stable,
// which can exceed the global 2s expect timeout on cold preview runs.
await expect(page.locator('.tl-canvas')).toHaveScreenshot({
maxDiffPixelRatio: 0.005,
timeout: 10_000,
})
}
test.describe('Overlay snapshots', () => {
test.skip(
({ isMobile }) => isMobile,
'Overlay snapshots are desktop-only; mobile overlays render differently (coarse-pointer rotate handle, different resize handle visibility).'
)
test.beforeEach(async ({ page, context }) => {
const url = page.url()
if (!url.includes('end-to-end')) {
await setup({ page, context } as any)
} else {
await hardResetEditor(page)
}
await prepare(page)
})
test('selection foreground: single shape', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'geo',
x: 220,
y: 160,
props: { w: 240, h: 160, geo: 'rectangle', dash: 'solid', fill: 'none' },
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('selection foreground: multiple shapes', async ({ page }) => {
await page.evaluate(() => {
editor.createShapes([
{
type: 'geo',
x: 180,
y: 140,
props: { w: 120, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
{
type: 'geo',
x: 360,
y: 200,
props: { w: 140, h: 120, geo: 'ellipse', dash: 'solid', fill: 'none' },
},
])
editor.selectAll()
})
await snapshotCanvas(page)
})
test('selection foreground: rotated shape', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'geo',
x: 260,
y: 180,
rotation: Math.PI / 6,
props: { w: 220, h: 140, geo: 'rectangle', dash: 'solid', fill: 'none' },
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: arrow endpoints', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'arrow',
x: 220,
y: 220,
props: {
start: { x: 0, y: 0 },
end: { x: 280, y: 0 },
dash: 'solid',
},
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: line with vertices', async ({ page }) => {
await page.evaluate(() => {
editor.createShape({
type: 'line',
x: 200,
y: 260,
props: {
dash: 'solid',
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 180, y: -60 },
a3: { id: 'a3', index: 'a3' as IndexKey, x: 360, y: 0 },
},
},
})
editor.selectAll()
})
await snapshotCanvas(page)
})
test('shape handles: line endpoints and hovered center handle', async ({ page }) => {
// Capture the two states a user sees on a selected line: the idle state
// where only the vertex handles at the start and end are visible, and
// the hover state where the midpoint create handle becomes visible
// with its hover halo.
await page.evaluate(() => {
editor.createShape({
type: 'line',
x: 200,
y: 200,
props: {
dash: 'solid',
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: 280, y: 280 },
},
},
})
editor.selectAll()
})
await snapshotCanvas(page)
// Force-hover the midpoint 'create' handle so the screenshot is frame-
// exact regardless of the browser's pointer coordinate rounding.
await page.evaluate(() => {
const shape = editor.getOnlySelectedShape()!
const handles = editor.getShapeHandles(shape)!
const midHandle = handles.find((h) => h.type === 'create')!
editor.overlays.setHoveredOverlay(`handle:${shape.id}:${midHandle.id}`)
})
await snapshotCanvas(page)
})
test('brush overlay', async ({ page }) => {
await page.evaluate(() => {
editor.updateInstanceState({ brush: { x: 180, y: 140, w: 320, h: 200 } })
})
await snapshotCanvas(page)
})
test('zoom brush overlay', async ({ page }) => {
await page.evaluate(() => {
editor.updateInstanceState({ zoomBrush: { x: 180, y: 140, w: 320, h: 200 } })
})
await snapshotCanvas(page)
})
test('snap indicator overlay: point snaps', async ({ page }) => {
await page.evaluate(() => {
editor.createShapes([
{
type: 'geo',
x: 180,
y: 160,
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
{
type: 'geo',
x: 380,
y: 160,
props: { w: 100, h: 100, geo: 'rectangle', dash: 'solid', fill: 'none' },
},
])
editor.snaps.setIndicators([
{
id: 'p-top',
type: 'points',
points: [
{ x: 230, y: 160 },
{ x: 430, y: 160 },
],
},
{
id: 'p-bottom',
type: 'points',
points: [
{ x: 230, y: 260 },
{ x: 430, y: 260 },
],
},
])
})
await snapshotCanvas(page)
})
test('scribble overlay', async ({ page }) => {
await page.evaluate(async () => {
const item = editor.scribbles.addScribble(
{ color: 'accent', size: 20, opacity: 0.8, taper: true },
'snapshot-scribble'
)
// Build a smooth arc of points. Advance the tick between each
// addPoint so ScribbleManager moves `next` into `scribble.points`.
const pts: Array<[number, number]> = []
for (let i = 0; i <= 20; i++) {
const t = i / 20
const x = 200 + t * 320
const y = 240 + Math.sin(t * Math.PI) * -60
pts.push([x, y])
}
for (const [x, y] of pts) {
editor.scribbles.addPoint(item.id, x, y, 0.5)
editor.scribbles.tick(16)
}
editor.scribbles.complete(item.id)
editor.scribbles.tick(16)
})
await snapshotCanvas(page)
})
})

View File

@@ -0,0 +1,685 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset, sleep } from '../shared-e2e'
declare const editor: Editor
const PAGE_MENU_ITEM_HEIGHT = 36
// The list reserves a few pixels of breathing room below the last row; the
// auto-fit and minimum heights both include it, so the e2e expectations must too.
const LIST_BOTTOM_PADDING = 4
const MIN_PAGE_MENU_LIST_HEIGHT = PAGE_MENU_ITEM_HEIGHT + LIST_BOTTOM_PADDING
const isMobileProject = () => test.info().project.name.includes('Mobile')
async function expectPageItemToBeCurrent(pageItem: Locator, isCurrent: boolean) {
await expect(pageItem).toHaveAttribute('data-iscurrent', isCurrent ? 'true' : 'false')
}
async function getPageMenuListHeight(pageList: Locator) {
return await pageList.evaluate((elm) => elm.getBoundingClientRect().height)
}
async function expectPageMenuListHeight(pageList: Locator, expectedHeight: number, tolerance = 1) {
await expect
.poll(async () => getPageMenuListHeight(pageList))
.toBeGreaterThanOrEqual(expectedHeight - tolerance)
await expect
.poll(async () => getPageMenuListHeight(pageList))
.toBeLessThanOrEqual(expectedHeight + tolerance)
}
async function dragPageMenuResizeHandle(page: Page, deltaY: number) {
const resizeHandle = page.locator('.tlui-page-menu__resize-handle')
const box = await resizeHandle.boundingBox()
if (!box) throw new Error('Could not find page menu resize handle')
const x = box.x + box.width / 2
const y = box.y + box.height / 2
await page.mouse.move(x, y)
await page.mouse.down()
await page.mouse.move(x, y + deltaY, { steps: 4 })
await page.mouse.up()
}
async function createPagesForReordering(page: Page) {
await page.evaluate(() => {
editor.createPage({ name: 'Page 2' })
editor.createPage({ name: 'Page 3' })
})
}
async function useCoarsePointer(page: Page) {
await page.evaluate(() => {
window.dispatchEvent(new PointerEvent('pointerdown', { pointerType: 'touch' }))
editor.updateInstanceState({ isCoarsePointer: true })
})
await expect.poll(() => page.evaluate(() => editor.getInstanceState().isCoarsePointer)).toBe(true)
}
async function getPageNames(page: Page) {
return await page.evaluate(() => editor.getPages().map((p) => p.name))
}
test.describe('page menu', () => {
test.beforeEach(setupOrReset)
test.afterEach(async ({ page }) => {
// press escape a few times
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
})
test('you can open and close the page menu', async ({ pageMenu }) => {
const { pagemenuButton, header } = pageMenu
await expect(header).toBeHidden()
await pagemenuButton.click()
await expect(header).toBeVisible()
await pagemenuButton.click()
await expect(header).toBeHidden()
})
test('The page list height fits its rows when opened, resized, and reset', async ({
page,
pageMenu,
}) => {
test.skip(isMobileProject(), 'Desktop page menu resize behavior')
await page.evaluate(() => {
window.localStorage.removeItem('tldraw_page_menu_list_height')
editor.createPage({ name: 'Page 2' })
editor.createPage({ name: 'Page 3' })
})
await pageMenu.pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const autoFitHeight = PAGE_MENU_ITEM_HEIGHT * 3 + LIST_BOTTOM_PADDING
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
await dragPageMenuResizeHandle(page, 120)
await expect
.poll(async () => getPageMenuListHeight(pageMenu.pageList))
.toBeGreaterThan(autoFitHeight)
await dragPageMenuResizeHandle(page, -300)
await expectPageMenuListHeight(pageMenu.pageList, MIN_PAGE_MENU_LIST_HEIGHT)
await dragPageMenuResizeHandle(page, 120)
await page.locator('.tlui-page-menu__resize-handle').dblclick()
await expectPageMenuListHeight(pageMenu.pageList, autoFitHeight)
})
test('You can change pages', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('create a second page', async () => {
const initialCount = await pageItems.count()
await pageMenu.createButton.click()
await sleep(100)
await expect(pageItems).toHaveCount(initialCount + 1)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape') // close the menu
})
await test.step('switch between pages', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(2)
const firstPage = pageItems.nth(0)
const secondPage = pageItems.nth(1)
// Wait for both pages to be visible
await expect(firstPage).toBeVisible()
await expect(secondPage).toBeVisible()
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(secondPage, true)
// Click on second page
await secondPage.locator('button').first().click()
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(secondPage, true)
// Click on first page
await firstPage.locator('button').first().click()
await expectPageItemToBeCurrent(firstPage, true)
await expectPageItemToBeCurrent(secondPage, false)
})
})
test('You can create a new page by pressing enter after opening the page menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, createButton } = pageMenu
await test.step('open page menu and get initial count', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('create new page using create button', async () => {
const initialCount = await pageItems.count()
await createButton.click()
await sleep(100)
await expect(pageItems).toHaveCount(initialCount + 1)
})
await test.step('confirm page creation with enter', async () => {
await page.keyboard.press('Enter')
})
})
test('On coarse pointers, canceling the new page prompt does not create a page', async ({
page,
pageMenu,
}) => {
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
const { pagemenuButton, createButton } = pageMenu
await pagemenuButton.click()
await useCoarsePointer(page)
expect(await getPageNames(page)).toEqual(['Page 1'])
page.once('dialog', async (dialog) => {
expect(dialog.type()).toBe('prompt')
await dialog.dismiss()
})
await createButton.tap()
expect(await getPageNames(page)).toEqual(['Page 1'])
})
test.describe('You can rename a page', () => {
test('You can rename a page by double clicking its name', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('double click to rename page', async () => {
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Should be in edit mode - look for input field
await expect(pageItem.locator('input')).toBeVisible()
})
await test.step('enter new name', async () => {
const input = pageItems.first().locator('input')
await expect(input).toBeVisible()
// Clear the input field more reliably
await input.clear()
await input.fill('My New Page')
await page.keyboard.press('Enter')
})
await test.step('verify page was renamed', async () => {
// Wait for the input to disappear (edit mode ends)
await expect(pageItems.first().locator('input')).toBeHidden()
// Wait a bit more for the rename to process
await sleep(200)
const pageItem = await pageMenu.getPageItem(0)
await expect(pageItem).toContainText('My New Page')
})
})
test('You can press enter to confirm a rename without closing the menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, header } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Double click to start editing
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode to activate
const input = pageItem.locator('input')
await expect(input).toBeVisible()
// Clear and type new name
await input.clear()
await input.fill('Renamed Page')
await page.keyboard.press('Enter')
// Wait for edit mode to end
await expect(input).toBeHidden()
// Menu should still be open
await expect(header).toBeVisible()
// Check that the page item now contains the new name
await expect(pageItem).toContainText('Renamed Page')
})
test('You can press enter after renaming a page to close the menu', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems, header } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Double click to start editing
const pageItem = await pageMenu.getPageItem(0)
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode and use robust input handling
const input = pageItem.locator('input')
await expect(input).toBeVisible()
await input.clear()
await input.fill('Another Renamed Page')
await page.keyboard.press('Enter')
await page.keyboard.press('Enter')
// Menu should be closed
await expect(header).toBeHidden()
})
test('You can press escape to cancel a page rename and restore the previous name', async ({
page,
pageMenu,
}) => {
const { pagemenuButton, pageItems } = pageMenu
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
// Get original page name
const pageItem = await pageMenu.getPageItem(0)
const originalName = await pageItem.getByRole('button').first().textContent()
// Double click to start editing
await pageItem.getByRole('button').first().dblclick()
// Wait for edit mode and use robust input handling
const input = pageItem.locator('input')
await expect(input).toBeVisible()
await input.clear()
await input.fill('Should Not Save')
await page.keyboard.press('Escape')
// Wait for edit mode to end
await expect(input).toBeHidden()
// Should have original name back
await expect(pageItem.getByRole('button').first()).toContainText(originalName || '')
})
test('When you create a new page, the new page is focused', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create a new page and verify it becomes active', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
// Create a new page
await pageMenu.createButton.click()
await sleep(100)
// To stop editing
await page.keyboard.press('Enter')
// Verify page count increased
await expect(pageItems).toHaveCount(initialCount + 1)
// The new page (last one) should be the active/focused page
const newPageItem = await pageMenu.getPageItem(initialCount)
await expectPageItemToBeCurrent(newPageItem, true)
// All other pages should not be active
for (let i = 0; i < initialCount; i++) {
const otherPageItem = await pageMenu.getPageItem(i)
await expectPageItemToBeCurrent(otherPageItem, false)
}
})
})
test('When you open the page menu, the current page is in view', async ({ page, pageMenu }) => {
const { pagemenuButton } = pageMenu
await test.step('create multiple pages', async () => {
await pagemenuButton.click()
// Create several pages to potentially cause scrolling
for (let i = 0; i < 5; i++) {
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
}
await page.keyboard.press('Escape')
})
await test.step('navigate to first page and verify it is in view', async () => {
await pagemenuButton.click()
// Click on the first page to make it current
const firstPage = await pageMenu.getPageItem(0)
await firstPage.locator('button').first().click()
// Close and reopen menu
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify the first page (current page) is visible in the page list
await expect(firstPage).toBeInViewport()
// Also verify it's marked as checked/active
await expectPageItemToBeCurrent(firstPage, true)
})
})
test('When you open the page menu, the current page is focused', async ({ page, pageMenu }) => {
const { pagemenuButton } = pageMenu
await test.step('create multiple pages', async () => {
await pagemenuButton.click()
// Create a few pages
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('switch to middle page and verify focus', async () => {
await pagemenuButton.click()
// Click on the middle page to make it current
const middlePage = await pageMenu.getPageItem(1)
await middlePage.locator('button').first().click()
// Close and reopen menu
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify the middle page is marked as the current/focused page
await expectPageItemToBeCurrent(middlePage, true)
// Verify other pages are not focused
const firstPage = await pageMenu.getPageItem(0)
const lastPage = await pageMenu.getPageItem(2)
await expectPageItemToBeCurrent(firstPage, false)
await expectPageItemToBeCurrent(lastPage, false)
})
})
})
test.describe('You can drag and drop pages to reorder them', () => {
test('Pages can be reordered by dragging the row itself', async ({ page, pageMenu }) => {
test.skip(isMobileProject(), 'Mobile page rows can only be reordered from the drag handle')
const { pagemenuButton } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await createPagesForReordering(page)
})
await test.step('drag page to new position by its row', async () => {
await pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
const firstButton = firstItem.locator('.tlui-page-menu__item__button')
await firstButton.dragTo(secondItem)
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
})
})
test('On coarse pointers, pages can only be reordered by dragging the handle', async ({
page,
pageMenu,
}) => {
test.skip(!isMobileProject(), 'Coarse-pointer page menu behavior')
const { pagemenuButton } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await createPagesForReordering(page)
})
await test.step('dragging the row itself does not reorder pages', async () => {
await pagemenuButton.click()
await useCoarsePointer(page)
await expect(pageMenu.pageItems).toHaveCount(3)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
await expect(firstItem.getByTestId('page-menu.item-drag-handle')).toBeVisible()
await firstItem.locator('.tlui-page-menu__item__button').dragTo(secondItem)
expect(await getPageNames(page)).toEqual(['Page 1', 'Page 2', 'Page 3'])
})
await test.step('dragging the handle reorders pages', async () => {
await useCoarsePointer(page)
const firstItem = await pageMenu.getPageItem(0)
const secondItem = await pageMenu.getPageItem(1)
await firstItem.getByTestId('page-menu.item-drag-handle').dragTo(secondItem)
await expect.poll(() => getPageNames(page)).toEqual(['Page 2', 'Page 1', 'Page 3'])
})
})
})
test.describe('You can delete a page', () => {
test('You can delete a page using the submenu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create an extra page so we can delete one', async () => {
await pagemenuButton.click()
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter') // confirm rename
await page.keyboard.press('Escape') // close the menu
})
await test.step('open page menu and verify we have multiple pages', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
expect(initialCount).toBeGreaterThan(1)
})
await test.step('access page submenu and delete', async () => {
const pageItem = await pageMenu.getPageItem(1)
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click delete option
const deleteButton = page.getByRole('menuitem', { name: /delete/i })
await expect(deleteButton).toBeVisible()
await deleteButton.click()
// Verify page count decreased
await expect(pageItems).toHaveCount(1)
})
})
})
test.describe('You can use the page menu', () => {
test('Right clicking a page item opens its submenu without reordering pages', async ({
page,
pageMenu,
}) => {
test.skip(isMobileProject(), 'Mobile emulation does not simulate right-click')
await createPagesForReordering(page)
await pageMenu.pagemenuButton.click()
await expect(pageMenu.pageItems).toHaveCount(3)
const firstPageItem = await pageMenu.getPageItem(0)
await firstPageItem.locator('.tlui-page-menu__item__button').click({ button: 'right' })
await expect(page.getByRole('menuitem', { name: /duplicate/i })).toBeVisible()
await expect(firstPageItem).toHaveAttribute('data-dragging', 'false')
expect(await getPageNames(page)).toEqual(['Page 1', 'Page 2', 'Page 3'])
})
test('You can duplicate a page from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('open page menu and get initial count', async () => {
await pagemenuButton.click()
await expect(pageItems.first()).toBeVisible()
})
await test.step('duplicate the first page', async () => {
const initialCount = await pageItems.count()
const pageItem = await pageMenu.getPageItem(0)
// Click the submenu button (three dots)
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click duplicate option
const duplicateButton = page.getByRole('menuitem', { name: /duplicate/i })
await expect(duplicateButton).toBeVisible()
await duplicateButton.click()
// Verify page count increased
await expect(pageItems).toHaveCount(initialCount + 1)
})
})
test('You can delete a page from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create an extra page so we can delete one', async () => {
await pagemenuButton.click()
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('delete a page using submenu', async () => {
await pagemenuButton.click()
const initialCount = await pageItems.count()
expect(initialCount).toBeGreaterThan(1)
const pageItem = await pageMenu.getPageItem(1)
// Click the submenu button
const submenuButton = pageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click delete option
const deleteButton = page.getByRole('menuitem', { name: /delete/i })
await expect(deleteButton).toBeVisible()
await deleteButton.click()
// Verify page count decreased
await expect(pageItems).toHaveCount(initialCount - 1)
})
})
test('You can move a page up from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await pagemenuButton.click()
// Create two additional pages (will have default names)
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('move the last page up', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(3)
// Get the last page (index 2) and move it up
const lastPageItem = await pageMenu.getPageItem(2)
// Click the submenu button
const submenuButton = lastPageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click move up option
const moveUpButton = page.getByRole('menuitem', { name: /move up/i })
await expect(moveUpButton).toBeVisible()
await moveUpButton.click()
// Wait a moment for the reorder to complete
await sleep(200)
// Close and reopen menu to get fresh state
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify we still have 3 pages - the operation worked
await expect(pageItems).toHaveCount(3)
})
})
test('You can move a page down from the page menu', async ({ page, pageMenu }) => {
const { pagemenuButton, pageItems } = pageMenu
await test.step('create multiple pages for reordering', async () => {
await pagemenuButton.click()
// Create two additional pages (will have default names)
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await pageMenu.createButton.click()
await sleep(100)
await page.keyboard.press('Enter')
await page.keyboard.press('Escape')
})
await test.step('move the first page down', async () => {
await pagemenuButton.click()
await expect(pageItems).toHaveCount(3)
// Get the first page (index 0) and move it down
const firstPageItem = await pageMenu.getPageItem(0)
// Click the submenu button
const submenuButton = firstPageItem.getByTestId('page-menu.item-submenu')
await submenuButton.click()
// Click move down option
const moveDownButton = page.getByRole('menuitem', { name: /move down/i })
await expect(moveDownButton).toBeVisible()
await moveDownButton.click()
// Wait a moment for the reorder to complete
await sleep(200)
// Close and reopen menu to get fresh state
await page.keyboard.press('Escape')
await pagemenuButton.click()
// Verify we still have 3 pages - the operation worked
await expect(pageItems).toHaveCount(3)
})
})
})
})

View File

@@ -0,0 +1,57 @@
import test, { expect, Page } from '@playwright/test'
import { Editor, TLTextShape } from 'tldraw'
import { setup } from '../shared-e2e'
declare const editor: Editor
test.describe('paste text measurement', () => {
test.beforeEach(setup)
async function pasteTextAndGetShape(page: Page, text: string, html?: string) {
return await page.evaluate(
async ({ text, html }) => {
await editor.putExternalContent({
type: 'text',
text,
html,
point: editor.getViewportPageBounds().center,
})
const shapes = editor
.getCurrentPageShapes()
.filter((s): s is TLTextShape => s.type === 'text')
const shape = shapes[shapes.length - 1]
return {
x: shape.x,
y: shape.y,
w: shape.props.w,
}
},
{ text, html }
)
}
test('pasting the same text as plain text or html produces the same shape', async ({ page }) => {
const plainText = await pasteTextAndGetShape(page, 'Hello world')
const withInlineStyles = await pasteTextAndGetShape(
page,
'Hello world',
`<p class="p1" style="margin: 0px; font: 400 12px Helvetica; color: rgb(0, 0, 0);">Hello world</p>`
)
const withProsemirrorMeta = await pasteTextAndGetShape(
page,
'Hello world',
`<meta charset="utf-8"><p dir="auto" data-pm-slice="0 0 []">Hello world</p>`
)
expect(plainText.x).toBe(withInlineStyles.x)
expect(plainText.y).toBe(withInlineStyles.y)
expect(plainText.w).toBe(withInlineStyles.w)
expect(plainText.x).toBe(withProsemirrorMeta.x)
expect(plainText.y).toBe(withProsemirrorMeta.y)
expect(plainText.w).toBe(withProsemirrorMeta.w)
})
})

View File

@@ -0,0 +1,163 @@
import { CDPSession, expect, Page } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
declare const editor: Editor
/*
* Playwright's input APIs can't produce a `pointercancel`, so these tests drive
* Chrome directly: a `touchCancel` through CDP makes the browser generate the
* pointercancel itself, which is the path a system-interrupted touch takes (an
* edge swipe, an incoming call). Checked against iPadOS Safari, which emits
* `pointercancel` with `pointerType: 'touch'` and `button: -1` for those
* gestures, and none at all for a rejected palm.
*/
type TouchEventType = 'touchStart' | 'touchEnd' | 'touchMove' | 'touchCancel'
type MouseEventType = 'mousePressed' | 'mouseReleased' | 'mouseMoved'
async function startTouchSession(page: Page): Promise<CDPSession> {
const cdp = await page.context().newCDPSession(page)
// The desktop project has no touch points of its own.
await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 })
return cdp
}
function touch(cdp: CDPSession, type: TouchEventType, points: [number, number][] = []) {
return cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: points.map(([x, y], i) => ({ x, y, id: i })),
})
}
function pen(cdp: CDPSession, type: MouseEventType, x: number, y: number) {
return cdp.send('Input.dispatchMouseEvent', {
type,
x,
y,
button: 'left',
buttons: type === 'mouseReleased' ? 0 : 1,
clickCount: 1,
pointerType: 'pen',
force: 0.5,
})
}
const getState = (page: Page) =>
page.evaluate(() => ({
path: editor.getPath(),
isPointing: editor.inputs.getIsPointing(),
buttons: [...editor.inputs.buttons],
}))
test.describe('Cancelled pointers', () => {
test.beforeEach(setupOrReset)
test('ends a select drag', async ({ page }) => {
const cdp = await startTouchSession(page)
await page.evaluate(() => {
editor.setCurrentTool('select')
})
await touch(cdp, 'touchStart', [[200, 300]])
await touch(cdp, 'touchMove', [[340, 420]])
// Pointer moves are queued until the next tick, so the transition into brushing
// lands a frame after the CDP call resolves.
await expect
.poll(() => getState(page))
.toMatchObject({ path: 'select.brushing', isPointing: true })
await touch(cdp, 'touchCancel')
// The button comes from the pointerdown: pointercancel reports -1, which would
// otherwise stay stuck in inputs.buttons.
expect(await getState(page)).toEqual({
path: 'select.idle',
isPointing: false,
buttons: [],
})
})
test('ends a draw stroke', async ({ page }) => {
const cdp = await startTouchSession(page)
await page.evaluate(() => {
editor.setCurrentTool('draw')
})
await touch(cdp, 'touchStart', [[300, 300]])
await touch(cdp, 'touchMove', [[380, 400]])
expect(await getState(page)).toMatchObject({ path: 'draw.drawing', isPointing: true })
await touch(cdp, 'touchCancel')
expect(await getState(page)).toMatchObject({ path: 'draw.idle', isPointing: false })
})
test('lets the next touch start a fresh stroke rather than continuing the old one', async ({
page,
}) => {
const cdp = await startTouchSession(page)
await page.evaluate(() => {
editor.setCurrentTool('draw')
})
await touch(cdp, 'touchStart', [[200, 300]])
await touch(cdp, 'touchMove', [[260, 360]])
await touch(cdp, 'touchCancel')
await touch(cdp, 'touchStart', [[160, 480]])
await touch(cdp, 'touchMove', [[220, 540]])
await touch(cdp, 'touchEnd')
// A draw tool left in its drawing state extends the cancelled shape instead of
// starting a new one, so the two gestures come back as a single stroke.
expect(await page.evaluate(() => editor.getCurrentPageShapes().length)).toBe(2)
})
/**
* iPadOS rejects a resting palm before it reaches the page, so these two never
* fire in practice on a real device. They pin the guards that keep a cancelled
* touch from ending the pen's own interaction if one ever does arrive.
*/
test.describe('in pen mode', () => {
test('a cancelled palm landing after the pen leaves the stroke alone', async ({ page }) => {
const cdp = await startTouchSession(page)
await page.evaluate(() => {
editor.updateInstanceState({ isPenMode: true })
editor.setCurrentTool('draw')
})
await pen(cdp, 'mousePressed', 300, 300)
await pen(cdp, 'mouseMoved', 380, 400)
await touch(cdp, 'touchStart', [[160, 500]])
await touch(cdp, 'touchCancel')
expect(await getState(page)).toMatchObject({ path: 'draw.drawing', isPointing: true })
})
test('a cancelled palm landing before the pen leaves the stroke alone', async ({ page }) => {
const cdp = await startTouchSession(page)
await page.evaluate(() => {
editor.updateInstanceState({ isPenMode: false })
editor.setCurrentTool('draw')
})
// The palm is accepted while pen mode is still off, so its button is recorded;
// the pen then turns pen mode on before the palm is cancelled. The pen press
// only auto-enables pen mode on a touch device (isDirectDisplayPen reads
// tlenv.isTouchDevice, fixed at load), and the desktop project enables touch
// through CDP after the page has loaded, so set it here instead.
await touch(cdp, 'touchStart', [[160, 500]])
await pen(cdp, 'mousePressed', 300, 300)
await pen(cdp, 'mouseMoved', 380, 400)
await page.evaluate(() => {
editor.updateInstanceState({ isPenMode: true })
})
await touch(cdp, 'touchCancel')
expect(await getState(page)).toMatchObject({ path: 'draw.drawing', isPointing: true })
})
})
})

View File

@@ -0,0 +1,562 @@
import { expect } from '@playwright/test'
import { type Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset, sleep } from '../shared-e2e'
declare const editor: Editor
test.describe('Rich text behaviour', () => {
test.beforeEach(setupOrReset)
test.beforeEach(async ({ page, toolbar, isMobile }) => {
// TODO: the mobile e2e test doesn't have the virtual keyboard at the moment.
if (isMobile) return
const { rectangle } = toolbar.tools
await rectangle.click()
await page.getByTestId('style.font.mono').first().click()
await page.mouse.click(150, 150)
await page.mouse.click(150, 150)
const isEditing = await page.evaluate(
() => editor.getEditingShapeId() === editor.getOnlySelectedShape()?.id
)
expect(isEditing).toBe(true)
// Wait for the toolbar to animate in.
await page.waitForTimeout(150)
// Type into the rich text editor.
const text = 'Hello, world!'
await page.keyboard.type(text)
// Select all the text.
const locator = page.getByTestId('rich-text-area').getByText('Hello, world!')
await locator.selectText()
await page.waitForTimeout(150)
})
test('selecting a style changes the style of the text', async ({
page,
richTextToolbar,
isMobile,
}) => {
// TODO: the mobile e2e test doesn't have the virtual keyboard at the moment.
if (isMobile) return
const toolsForHTMLStyle = [
{ name: 'bold', tag: 'strong' },
{ name: 'italic', tag: 'em' },
// // { name: 'strike', tag: 's' },
{ name: 'highlight', tag: 'mark' },
{ name: 'code', tag: 'code' },
// // Link is tested separately
// { name: 'heading', tag: 'h3' },
{ name: 'bulletList', tag: 'ul' },
]
for (const tool of toolsForHTMLStyle) {
await richTextToolbar.clickTool(richTextToolbar.tools[tool.name])
await page.waitForTimeout(33)
await richTextToolbar.isSelected(richTextToolbar.tools[tool.name])
// Find the contenteditable on the page and check the `tag`
const isTagEnabled = await page.evaluate(
async (toolTag) => {
const richTextArea = document.querySelector('[data-testid="rich-text-area"]')?.innerHTML
const renderedText = document.querySelector('.tl-rich-text')?.innerHTML
// Check tool.tag is in the innerHTML of the richTextArea and renderedText.
return richTextArea?.includes(`<${toolTag}`) && renderedText?.includes(`<${toolTag}`)
},
[tool.tag]
)
expect(isTagEnabled).toBe(true)
// Turn it off for the next thing to be tested.
await richTextToolbar.clickTool(richTextToolbar.tools[tool.name])
}
})
test('keeps text visible when ProseMirror hides the selection', async ({ page, isMobile }) => {
// TODO: the mobile e2e test doesn't have the virtual keyboard at the moment.
if (isMobile) return
// Triple clicking a list's bullet gives ProseMirror a NodeSelection over the whole list. It
// then hides the native selection, and our ::selection rule would go on painting the text in
// the selected-contrast color with no highlight behind it, leaving it invisible.
await page.keyboard.type('- aeouaeou')
await page.keyboard.press('Enter')
await page.keyboard.type('second item')
await sleep(150)
const item = page.locator('.ProseMirror ul > li').first()
const box = (await item.boundingBox())!
await page.mouse.click(box.x - 4, box.y + box.height / 2, { clickCount: 3 })
await sleep(250)
const state = await page.evaluate(() => {
// Which ::selection rules that set a text color still apply to the editor right now?
// Reading it off the live stylesheet rather than a hard-coded selector, so the test
// keeps meaning what it says if the rule is rewritten.
const input = document.querySelector('.tl-text-input')!
const colouring: string[] = []
for (const sheet of Array.from(document.styleSheets)) {
let rules
try {
rules = sheet.cssRules
} catch {
continue // cross-origin sheet
}
for (const rule of Array.from(rules) as CSSStyleRule[]) {
if (!rule.selectorText?.includes('::selection')) continue
if (!rule.style?.color) continue
const originating = rule.selectorText.replaceAll('::selection', '')
if (input.matches(originating)) colouring.push(rule.selectorText)
}
}
return {
selection: editor.getRichTextEditor()!.state.selection.constructor.name,
hidden: !!document.querySelector('.ProseMirror.ProseMirror-hideselection'),
colouring,
}
})
expect(state.selection).toBe('NodeSelection')
expect(state.hidden).toBe(true)
// With no highlight to sit behind it, nothing may recolor the text.
expect(state.colouring).toEqual([])
})
test('adding and removing a link', async ({ page, toolbar, richTextToolbar, isMobile }) => {
// TODO: the mobile e2e test doesn't have the virtual keyboard at the moment.
if (isMobile) return
const { rectangle } = toolbar.tools
await richTextToolbar.clickTool(richTextToolbar.tools.link)
await page.keyboard.type('example.com')
await page.keyboard.press('Enter')
// Click away to get out of edit mode.
await page.mouse.click(150, 400)
await rectangle.click()
await page.mouse.click(150, 400)
await page.waitForTimeout(150)
// Check the link has been rendered.
// We could test this without clicking away but I want to
// test that the link is still there after clicking away.
const isLinkSet = await page.evaluate(async () => {
return !!document.querySelector('.tl-rich-text a[href="https://example.com"]')
})
expect(isLinkSet).toBe(true)
// Now, let's test removing the link.
// Click on the shape to go into edit mode again.
await page.mouse.dblclick(250, 200)
await page.waitForTimeout(150)
const locator = page.getByTestId('rich-text-area').getByText('Hello, world!')
await locator.selectText()
// Make the toolbar show up (isMousingAround needs to be true)
await page.mouse.move(100, 100)
await page.waitForTimeout(150)
await richTextToolbar.clickTool(richTextToolbar.tools.link)
await page.waitForTimeout(150)
await richTextToolbar.clickTool(richTextToolbar.tools.linkRemove)
const isLinkRemoved = await page.evaluate(async () => {
return !document.querySelector('.tl-rich-text a[href="https://example.com"]')
})
expect(isLinkRemoved).toBe(true)
})
test('clicking outside the link editor saves the link', async ({
page,
toolbar,
richTextToolbar,
isMobile,
}) => {
// TODO: the mobile e2e test doesn't have the virtual keyboard at the moment.
if (isMobile) return
const { rectangle } = toolbar.tools
await richTextToolbar.clickTool(richTextToolbar.tools.link)
await page.keyboard.type('example.com')
// Click away to blur the link input (instead of pressing Enter)
await page.mouse.click(150, 400)
await rectangle.click()
await page.mouse.click(150, 400)
await page.waitForTimeout(150)
// Check the link has been saved despite not pressing Enter
const isLinkSet = await page.evaluate(async () => {
return !!document.querySelector('.tl-rich-text a[href="https://example.com"]')
})
expect(isLinkSet).toBe(true)
})
// Text selection (the auto select all) doesn't seem to be working in our playwright tests
test('shows the toolbar when double clicking a shape to select all', async ({
page,
richTextToolbar,
isMobile,
}) => {
if (isMobile) return
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.mouse.click(550, 550)
// bar should not be visible when nothing is selected
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(0)
await page.mouse.move(150, 150)
await page.mouse.dblclick(150, 150)
// bar should be visible (all of the text should be selected)
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(1)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'true')
await page.keyboard.type('alex chilton')
expect(page.getByTestId('rich-text-area')).toHaveText('alex chilton')
// bar should not be visible when nothing is selected
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(1)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'false')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
// bar should not be visible when nothing is selected / not editing
await sleep(400)
await expect(richTextToolbar.container).toHaveCount(0)
await page.mouse.move(150, 150)
await page.mouse.click(150, 150)
await page.mouse.click(150, 150)
// bar should be visible (all of the text should be selected)
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(1)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'true')
await page.keyboard.type('big star')
expect(page.getByTestId('rich-text-area')).toHaveText('big star')
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(1)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'false')
})
// ...but it does seem to work with the enter key
test('shows the toolbar when pressing enter on a shape to select all', async ({
page,
isMobile,
richTextToolbar,
}) => {
if (isMobile) return
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
// bar should not be visible when nothing is selected
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(0)
await page.mouse.click(150, 150) // select the shape
await page.locator('.tl-container').first().focus() // explicitly focus the container
await page.keyboard.press('Enter') // to start editing the shape
// bar should be visible (all of the text should be selected)
await sleep(200)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'true')
await page.keyboard.type('alex chilton')
expect(page.getByTestId('rich-text-area')).toHaveText('alex chilton')
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'false')
await sleep(200)
await expect(richTextToolbar.container).toHaveCount(1)
// deselect the shape
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await page.keyboard.press('Escape')
await sleep(200)
// select it and start editing it again
await page.mouse.click(150, 150)
await page.locator('.tl-container').first().focus() // explicitly focus the container
await page.keyboard.press('Enter')
// bar should be visible (all of the text should be selected)
await sleep(200)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'true')
await page.keyboard.type('big star')
expect(page.getByTestId('rich-text-area')).toHaveText('big star')
// bar should not be visible when nothing is selected
await sleep(200)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'false')
})
test('positions correctly with selected text', async ({ isMobile, page, richTextToolbar }) => {
if (isMobile) return // can't test this on mobile
const PARA1 = 'whats that song?'
const PARA2 = 'Im in love with that song'
await page.keyboard.type(PARA1)
await sleep(200)
await page.keyboard.press('Enter')
await page.keyboard.type(PARA2)
await sleep(200)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'false')
// selects an empty space between "in" and "love"
await page.mouse.dblclick(150, 150)
// takes a moment for the toolbar to show up
await sleep(200)
await expect(richTextToolbar.container).toHaveAttribute('data-visible', 'true')
const originalSelectedTextBounds = await richTextToolbar.getSelectionBounds()
{
// Expect it to be above the currently selected text (the second paragraph)
const toolbarRect = await page.getByTestId('rich-text.bold').boundingBox()
expect(Math.round(toolbarRect!.y)).toBe(
Math.round(originalSelectedTextBounds!.y - toolbarRect!.height - 8)
)
}
// Select the next line too
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
// There's a delay here before the toolbar position moves
{
// Even though we're now selecting both paragraphs, the toolbar should still
// be above the second paragraph because the timer hasn't expired
const toolbarRect = await page.getByTestId('rich-text.bold').boundingBox()
expect(Math.round(toolbarRect!.y)).toBe(
Math.round(originalSelectedTextBounds!.y - toolbarRect!.height - 8)
)
}
// Let's test to see if the timer works by moving the second back to where it was.
await sleep(100)
// And go down instead
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
// Wait for the delay to expire
await sleep(500)
{
// Expect it to be above the currently selected text (the second paragraph)
const toolbarRect = await page.getByTestId('rich-text.bold').boundingBox()
const selectedTextRect = await richTextToolbar.getSelectionBounds()
expect(Math.round(toolbarRect!.y)).toBe(
Math.round(selectedTextRect!.y - toolbarRect!.height - 8)
)
}
// Go all the way back up again and wait for the delay
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
await page.keyboard.press('ArrowUp')
// Let the delay expire again
await sleep(500)
{
// now the toolbar should be above the first paragraph
const toolbarRect = await page.getByTestId('rich-text.bold').boundingBox()
const selectedTextRect = await richTextToolbar.getSelectionBounds()
expect(Math.round(toolbarRect!.y)).toBe(
Math.round(selectedTextRect!.y - toolbarRect!.height - 8)
)
}
})
test('positions correctly when rotated', async ({ isMobile, page, richTextToolbar }) => {
if (isMobile) return // can't test this on mobile
const PARA1 = 'Never traveled far'
const PARA2 = 'Without a little star'
await page.keyboard.type(PARA1)
await page.keyboard.press('Enter')
await page.keyboard.type(PARA2)
// Deselct the shape
await page.keyboard.press('Escape')
// Rotate the shape
await page.getByTestId('actions-menu.button').click()
await page.getByTestId('actions-menu.rotate-cw').click()
await page.mouse.click(300, 300)
await page.keyboard.press('Escape')
await sleep(150)
// Select all text
await page.mouse.dblclick(150, 150)
await sleep(150)
// Select two lines at the end of the text
await page.keyboard.press('ArrowRight')
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowUp')
await sleep(500)
// The toolbar should be just above the rotated selected text
const toolbarRect = await page.getByTestId('rich-text.bold').boundingBox()
const selectedTextRect = await richTextToolbar.getSelectionBounds()
expect(Math.round(toolbarRect!.y)).toBe(
Math.round(selectedTextRect!.y - toolbarRect!.height - 8)
)
// historically this has been flaky without the sleep
await sleep(2000)
})
test('hides and shows based on selection changes', async ({
isMobile,
page,
richTextToolbar,
}) => {
if (isMobile) return // can't test this on mobile
// clear selection
await page.keyboard.press('ArrowRight')
await sleep(200)
await expect(richTextToolbar.container).toHaveCSS('opacity', '0')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowLeft')
await page.keyboard.up('Shift')
await sleep(200)
// now it should be visible and interactive
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'all')
// clear selection again
await page.keyboard.press('ArrowRight')
// should still be visible for a moment
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
// But not interactive
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
// Without waiting, select the text again
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowLeft')
await page.keyboard.up('Shift')
// Should be back to normal now
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'all')
// no changes...
await sleep(200)
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'all')
// clear selection again
await page.keyboard.press('ArrowRight')
await sleep(200)
// And gone gain after a delay
await expect(richTextToolbar.container).toHaveCSS('opacity', '0')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
})
test('hides and shows based on mouse down changes', async ({
isMobile,
page,
richTextToolbar,
}) => {
if (isMobile) return // can't test this on mobile
// Select the end of the text
await page.keyboard.press('ArrowRight')
await page.keyboard.down('Shift')
await page.keyboard.press('ArrowLeft')
await page.keyboard.up('Shift')
await sleep(200)
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'all')
// Now mouse down on the text
await page.mouse.move(100, 150)
await page.mouse.down()
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
await sleep(200)
await expect(richTextToolbar.container).toHaveCSS('opacity', '0')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
await page.mouse.move(150, 150)
await page.mouse.up()
await expect(richTextToolbar.container).toHaveCSS('opacity', '0')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'none')
await sleep(200)
await expect(richTextToolbar.container).toHaveCSS('opacity', '1')
await expect(richTextToolbar.container).toHaveCSS('pointer-events', 'all')
// historically this has been flaky without the sleep
await sleep(2000)
})
test.skip('positions correctly at the edges of the screen', async ({ isMobile }) => {
if (isMobile) return // can't test this on mobile
})
test.skip('positions correctly when shape size changes while selection is open', async ({
isMobile,
}) => {
if (isMobile) return // can't test this on mobile
})
})

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
test.describe('more rich text', () => {
test.beforeEach(setupOrReset)
test('Double click from select tool to create and edit text on the canvas', async ({
page,
toolbar,
}) => {
await toolbar.tools.select.click()
await page.mouse.dblclick(150, 150)
// Wait for the contenteditable to actually be focused, not just visible.
// The editor focuses on a deferred timeout after mounting, so typing as
// soon as the element is visible drops the first keystroke.
await page.waitForFunction(
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
)
await page.keyboard.type('id like to go to india')
await expect(page.getByTestId('rich-text-area')).toHaveText('id like to go to india')
})
test('Click with text tool to create and edit text on the canvas', async ({ page, toolbar }) => {
await toolbar.tools.text.click()
await page.mouse.click(150, 150)
// Wait for the contenteditable to actually be focused (not just visible)
// so the first keystroke isn't dropped.
await page.waitForFunction(
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
)
await page.keyboard.type('Live in a big white house in the forest')
await expect(page.getByTestId('rich-text-area')).toHaveText(
'Live in a big white house in the forest'
)
// expect canvas overlays to be visible (indicators render there when editing)
const canvasIndicator = page.locator('.tl-canvas-overlays')
await expect(canvasIndicator).toBeVisible()
})
test('Click and drag with text tool to create and edit fixed-width text on the canvas', async ({
page,
toolbar,
}) => {
await toolbar.tools.text.click()
await page.mouse.move(150, 150)
await page.mouse.down()
await page.mouse.move(350, 150, { steps: 10 })
await page.mouse.up()
// Wait for the contenteditable to actually be focused (not just visible)
// so the first keystroke isn't dropped.
await page.waitForFunction(
() => document.activeElement === document.querySelector('.tl-shape [contenteditable]')
)
await page.keyboard.type('Drink gin and tonic and play a grand piano')
await expect(page.getByTestId('rich-text-area')).toHaveText(
'Drink gin and tonic and play a grand piano'
)
// expect canvas overlays to be visible (indicators render there when editing)
const canvasIndicator = page.locator('.tl-canvas-overlays')
await expect(canvasIndicator).toBeVisible()
})
})

View File

@@ -0,0 +1,44 @@
import * as fs from 'fs'
import path from 'path'
import test from '@playwright/test'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
// get all example slugs by finding README.md files in the nested category folders
const examplesRoot = path.join(__dirname, '../../src/examples')
function getExampleSlugs(dir: string): string[] {
const slugs: string[] = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue
const subdir = path.join(dir, entry.name)
if (fs.existsSync(path.join(subdir, 'README.md'))) {
slugs.push(entry.name)
} else {
slugs.push(...getExampleSlugs(subdir))
}
}
return slugs
}
const examplesFolderList = getExampleSlugs(examplesRoot)
const examplesWithoutCanvas = [
// only shows an image, not the canvas
'image-component',
// links out to a different repo
'yjs',
// starts by asking the user to select an image
'image-annotator',
// starts by asking the user to open a pdf
'pdf-editor',
// starts by asking the user to select a pdf (built on pdf-editor)
'exam-marking',
]
const examplesToTest = examplesFolderList.filter((route) => !examplesWithoutCanvas.includes(route))
test.describe('Routes', () => {
for (const example of examplesToTest) {
test(example, async ({ page }) => {
await page.goto(`http://localhost:5420/${example}/full`)
await page.waitForSelector('.tl-canvas')
})
}
})

View File

@@ -0,0 +1,160 @@
import test, { expect, Page } from '@playwright/test'
import { Editor } from 'tldraw'
import { setup } from '../shared-e2e'
declare const editor: Editor
// Regression coverage for #7720: RTL text alignment was reversed in PNG/SVG exports — `start`
// rendered the text flush-left and `end` flush-right, the opposite of how the canvas (and the
// logical `text-align` the editor uses) lays RTL text out. The hardcoded physical
// `.tl-text-wrapper[data-align] { text-align: left|right }` rules that caused it were removed in
// #8410 (the theme-system refactor), which moved alignment to inline logical `text-align`; this
// test locks that in so the physical mapping can't sneak back.
//
// It exports a two-line shape (a long line over a short line) and checks which edge the short line
// flushes to by scanning painted pixels per line-band — fully self-relative, so it doesn't depend
// on the (fallback) font that renders Arabic.
interface LineBands {
imageWidth: number
long: { left: number; right: number }
short: { left: number; right: number }
}
// Export a two-paragraph (long, short) autosize text shape and return the painted ink extents of
// the top (long) and bottom (short) lines.
async function measureLineBands(
page: Page,
opts: { long: string; short: string; textAlign: 'start' | 'end' }
): Promise<LineBands> {
return await page.evaluate(
async ({ long, short, textAlign }) => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
ed.createShape({
id: 'shape:align',
type: 'text',
x: 0,
y: 0,
props: {
richText: {
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: long }] },
{ type: 'paragraph', content: [{ type: 'text', text: short }] },
],
},
font: 'draw',
size: 'xl',
autoSize: true,
textAlign,
color: 'black',
},
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
const { url } = await ed.toImageDataUrl(['shape:align'], {
format: 'png',
background: false,
padding: 0,
scale: 2,
})
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = url
})
const w = img.naturalWidth
const h = img.naturalHeight
const cv = ed.getContainerDocument().createElement('canvas')
cv.width = w
cv.height = h
const ctx = cv.getContext('2d', { willReadFrequently: true })
ctx.drawImage(img, 0, 0)
const data = ctx.getImageData(0, 0, w, h).data
// Scan a horizontal band [y0, y1) for the left/right extent of painted ink. The two lines
// stack, so the top ~45% is the long line and the bottom ~45% is the short line; the
// middle is skipped to keep ascenders/descenders from bleeding across the boundary.
function band(y0: number, y1: number) {
let left = Infinity
let right = -Infinity
for (let y = y0; y < y1; y++) {
for (let x = 0; x < w; x++) {
if (data[(y * w + x) * 4 + 3] > 10) {
if (x < left) left = x
if (x > right) right = x
}
}
}
return { left, right }
}
return {
imageWidth: w,
long: band(0, Math.floor(h * 0.45)),
short: band(Math.ceil(h * 0.55), h),
}
},
{ long: opts.long, short: opts.short, textAlign: opts.textAlign }
)
}
const ARABIC_LONG = 'مرحباً بكم في تطبيق تلدرو الرائع'
const ARABIC_SHORT = 'سطر قصير'
const LATIN_LONG = 'The quick brown fox jumps over'
const LATIN_SHORT = 'short'
// Edges align (same flush side) within a few px; the indent on the other side is large (the lines
// differ a lot in length), so a generous gap keeps the direction check unambiguous.
const FLUSH_TOL = 6
const MIN_INDENT = 30
test.describe('RTL text export alignment (#7720)', () => {
test.beforeEach(setup)
test('RTL start aligns lines flush-right', async ({ page }) => {
const { long, short } = await measureLineBands(page, {
long: ARABIC_LONG,
short: ARABIC_SHORT,
textAlign: 'start',
})
// start === right for RTL: the short line's right edge meets the long line's, and it's
// indented from the left.
expect(Math.abs(short.right - long.right)).toBeLessThanOrEqual(FLUSH_TOL)
expect(short.left - long.left).toBeGreaterThan(MIN_INDENT)
})
test('RTL end aligns lines flush-left', async ({ page }) => {
const { long, short } = await measureLineBands(page, {
long: ARABIC_LONG,
short: ARABIC_SHORT,
textAlign: 'end',
})
// end === left for RTL.
expect(Math.abs(short.left - long.left)).toBeLessThanOrEqual(FLUSH_TOL)
expect(long.right - short.right).toBeGreaterThan(MIN_INDENT)
})
test('LTR start aligns lines flush-left', async ({ page }) => {
const { long, short } = await measureLineBands(page, {
long: LATIN_LONG,
short: LATIN_SHORT,
textAlign: 'start',
})
expect(Math.abs(short.left - long.left)).toBeLessThanOrEqual(FLUSH_TOL)
expect(long.right - short.right).toBeGreaterThan(MIN_INDENT)
})
test('LTR end aligns lines flush-right', async ({ page }) => {
const { long, short } = await measureLineBands(page, {
long: LATIN_LONG,
short: LATIN_SHORT,
textAlign: 'end',
})
expect(Math.abs(short.right - long.right)).toBeLessThanOrEqual(FLUSH_TOL)
expect(short.left - long.left).toBeGreaterThan(MIN_INDENT)
})
})

View File

@@ -0,0 +1,158 @@
import { expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { getAllShapeTypes, setupOrReset } from '../shared-e2e'
declare const editor: Editor
const clickableShapeCreators = [
{ tool: 'draw', shape: 'draw' },
{ tool: 'frame', shape: 'frame' },
{ tool: 'note', shape: 'note' },
{ tool: 'text', shape: 'text' },
{ tool: 'rectangle', shape: 'geo' },
{ tool: 'ellipse', shape: 'geo' },
{ tool: 'triangle', shape: 'geo' },
{ tool: 'diamond', shape: 'geo' },
{ tool: 'cloud', shape: 'geo' },
{ tool: 'hexagon', shape: 'geo' },
// { tool: 'octagon', shape: 'geo' },
{ tool: 'star', shape: 'geo' },
{ tool: 'heart', shape: 'geo' },
{ tool: 'rhombus', shape: 'geo' },
{ tool: 'oval', shape: 'geo' },
// { tool: 'trapezoid', shape: 'geo' },
{ tool: 'arrow-right', shape: 'geo' },
{ tool: 'arrow-left', shape: 'geo' },
{ tool: 'arrow-up', shape: 'geo' },
{ tool: 'arrow-down', shape: 'geo' },
{ tool: 'x-box', shape: 'geo' },
{ tool: 'check-box', shape: 'geo' },
]
const draggableShapeCreators = [
{ tool: 'draw', shape: 'draw' },
{ tool: 'arrow', shape: 'arrow' },
{ tool: 'frame', shape: 'frame' },
{ tool: 'note', shape: 'note' },
{ tool: 'text', shape: 'text' },
{ tool: 'line', shape: 'line' },
{ tool: 'rectangle', shape: 'geo' },
{ tool: 'ellipse', shape: 'geo' },
{ tool: 'triangle', shape: 'geo' },
{ tool: 'diamond', shape: 'geo' },
{ tool: 'cloud', shape: 'geo' },
{ tool: 'hexagon', shape: 'geo' },
// { tool: 'octagon', shape: 'geo' },
{ tool: 'star', shape: 'geo' },
{ tool: 'rhombus', shape: 'geo' },
{ tool: 'oval', shape: 'geo' },
{ tool: 'heart', shape: 'geo' },
// { tool: 'trapezoid', shape: 'geo' },
{ tool: 'arrow-right', shape: 'geo' },
{ tool: 'arrow-left', shape: 'geo' },
{ tool: 'arrow-up', shape: 'geo' },
{ tool: 'arrow-down', shape: 'geo' },
{ tool: 'x-box', shape: 'geo' },
{ tool: 'check-box', shape: 'geo' },
]
const otherTools = [{ tool: 'select' }, { tool: 'eraser' }, { tool: 'laser' }]
test.describe('Shape Tools', () => {
test.beforeEach(setupOrReset)
test('creates shapes with other tools', async ({ toolbar, page }) => {
expect(await getAllShapeTypes(page)).toEqual([])
for (const { tool } of otherTools) {
// Find and click the button
if (!(await page.getByTestId(`tools.${tool}`).isVisible())) {
if (!(await toolbar.moreToolsButton.isVisible())) {
throw Error(`Tool more is not visible`)
}
await toolbar.moreToolsButton.click()
if (!(await page.getByTestId(`tools.more.${tool}`).isVisible())) {
throw Error(`Tool in more panel is not visible`)
}
await page.getByTestId(`tools.more.${tool}`).click()
await toolbar.moreToolsButton.click()
}
if (!(await page.getByTestId(`tools.${tool}`).isVisible())) {
throw Error(`Tool ${tool} is not visible`)
}
await page.getByTestId(`tools.${tool}`).click()
// Button should be selected
await expect(page.getByTestId(`tools.${tool}`)).toHaveAttribute('aria-pressed', 'true')
}
})
test('creates shapes clickable tools', async ({ page, toolbar }) => {
expect(await getAllShapeTypes(page)).toEqual([])
for (const { tool, shape } of clickableShapeCreators) {
// Find and click the button
if (!(await page.getByTestId(`tools.${tool}`).isVisible())) {
await toolbar.moreToolsButton.click()
await page.getByTestId(`tools.more.${tool}`).click()
await toolbar.moreToolsButton.click()
}
await page.getByTestId(`tools.${tool}`).click()
// Button should be selected
await expect(page.getByTestId(`tools.${tool}`)).toHaveAttribute('aria-pressed', 'true')
// Click on the page
await page.mouse.click(200, 200)
// We should have a corresponding shape in the page
expect(await getAllShapeTypes(page)).toEqual([shape])
// Reset for next time using API (faster than keyboard)
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.setCurrentTool('select')
})
}
expect(await getAllShapeTypes(page)).toEqual([])
})
test('creates shapes with draggable tools', async ({ page, toolbar }) => {
expect(await getAllShapeTypes(page)).toEqual([])
for (const { tool, shape } of draggableShapeCreators) {
// Find and click the button
if (!(await page.getByTestId(`tools.${tool}`).isVisible())) {
await toolbar.moreToolsButton.click()
await page.getByTestId(`tools.more.${tool}`).click()
await toolbar.moreToolsButton.click()
}
await page.getByTestId(`tools.${tool}`).click()
// Button should be selected
await expect(page.getByTestId(`tools.${tool}`)).toHaveAttribute('aria-pressed', 'true')
// Click and drag
await page.mouse.move(200, 200)
await page.mouse.down()
await page.mouse.move(250, 250)
await page.mouse.up()
// We should have a corresponding shape in the page
expect(await getAllShapeTypes(page)).toEqual([shape])
// Reset for next time using API (faster than keyboard)
await page.evaluate(() => {
editor.selectAll().deleteShapes(editor.getSelectedShapeIds())
editor.setCurrentTool('select')
})
}
})
})

View File

@@ -0,0 +1,126 @@
import { expect } from '@playwright/test'
import { Editor, TLGeoShape } from 'tldraw'
import test from '../fixtures/fixtures'
import { getAllShapeTypes, setupOrReset } from '../shared-e2e'
declare const editor: Editor
test.describe('smoke tests', () => {
test.beforeEach(setupOrReset)
test('create a shape on the canvas', async ({ page }) => {
await page.keyboard.press('r')
await page.mouse.move(10, 50)
await page.mouse.down()
await page.mouse.up()
await page.keyboard.press('r')
await page.mouse.move(10, 250)
await page.mouse.down()
await page.mouse.move(100, 350)
await page.mouse.up()
expect(await getAllShapeTypes(page)).toEqual(['geo', 'geo'])
})
test('undo and redo', async ({ page }) => {
// buttons should be disabled when there is no history
expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// create a shape
await page.keyboard.press('r')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(200, 200)
await page.mouse.up()
expect(await getAllShapeTypes(page)).toEqual(['geo'])
// We should have an undoable shape
expect(page.getByTestId('quick-actions.undo')).not.toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).toBeDisabled()
// Click the undo button to undo the shape
await page.getByTestId('quick-actions.undo').click()
expect(await getAllShapeTypes(page)).toEqual([])
expect(page.getByTestId('quick-actions.undo')).toBeDisabled()
expect(page.getByTestId('quick-actions.redo')).not.toBeDisabled()
// Click the redo button to redo the shape
await page.getByTestId('quick-actions.redo').click()
expect(await getAllShapeTypes(page)).toEqual(['geo'])
expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('quick-actions.redo').isDisabled()).toBe(true)
})
test('style panel + undo and redo squashing', async ({ page, toolbar }) => {
await page.keyboard.press('r')
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.up()
expect(await getAllShapeTypes(page)).toEqual(['geo'])
const getSelectedShapeColor = async () =>
await page.evaluate(() => (editor.getSelectedShapes()[0] as TLGeoShape).props.color)
// change style
expect(await getSelectedShapeColor()).toBe('black')
// when on a mobile device...
const hasMobileMenu = await toolbar.mobileStylesButton.isVisible()
if (hasMobileMenu) {
// open the style menu
await toolbar.mobileStylesButton.click()
}
// Click the light-blue color
await page.getByTestId('style.color.light-blue').click()
expect(await getSelectedShapeColor()).toBe('light-blue')
// now drag from blue to orange; the color should change as we drag
// but when we undo, we should ignore the colors which were changed
// before the final color was chosen; i.e. the history should think
// the color went from black to light blue to orange, though the shape
// actually changed from black to light blue to blue to light blue to
// yellow and then to orange.
// start a pointer down over the blue color button
await page.getByTestId('style.color.blue').hover()
await page.mouse.down()
expect(await getSelectedShapeColor()).toBe('blue')
// now move across to the other colors before releasing
await page.getByTestId('style.color.light-blue').hover()
expect(await getSelectedShapeColor()).toBe('light-blue')
await page.getByTestId('style.color.yellow').hover()
expect(await getSelectedShapeColor()).toBe('yellow')
await page.getByTestId('style.color.orange').hover()
expect(await getSelectedShapeColor()).toBe('orange')
await page.mouse.up()
// Now undo and redo
const undo = page.getByTestId('quick-actions.undo')
const redo = page.getByTestId('quick-actions.redo')
await undo.click() // orange -> light blue
expect(await getSelectedShapeColor()).toBe('light-blue') // skipping squashed colors!
await redo.click() // light blue -> orange
expect(await getSelectedShapeColor()).toBe('orange') // skipping squashed colors!
await undo.click() // orange -> light blue
await undo.click() // light blue -> black
expect(await getSelectedShapeColor()).toBe('black')
await redo.click() // black -> light blue
await redo.click() // light-blue -> orange
expect(await page.getByTestId('quick-actions.undo').isDisabled()).not.toBe(true)
expect(await page.getByTestId('quick-actions.redo').isDisabled()).toBe(true)
})
})

View File

@@ -0,0 +1,159 @@
import { expect } from '@playwright/test'
import { Editor } from 'tldraw'
import test from '../fixtures/fixtures'
import { setupOrReset } from '../shared-e2e'
declare const editor: Editor
test.describe('Style selection behaviour', () => {
test.beforeEach(setupOrReset)
test('selecting a style hints the button', async ({ isMobile, stylePanel, toolbar }) => {
const { blue, black } = stylePanel.colors
const { solid, none } = stylePanel.fill
if (isMobile) {
await toolbar.mobileStylesButton.click()
}
// these are hinted by default
await stylePanel.isActive(black)
await stylePanel.isActive(none)
// these are not hinted by default
await stylePanel.isInactive(solid)
await stylePanel.isInactive(blue)
await blue.click()
await stylePanel.isActive(blue)
await stylePanel.isInactive(black)
await solid.click()
await stylePanel.isActive(solid)
await stylePanel.isInactive(none)
// this should not change the hint state of color buttons
await stylePanel.isActive(blue)
})
test('the fill dropdown trigger tooltip describes the dropdown, not the current fill', async ({
isMobile,
stylePanel,
toolbar,
}) => {
const { solid } = stylePanel.fill
if (isMobile) {
await toolbar.mobileStylesButton.click()
}
// when the current fill lives outside the dropdown (the default "none", or "solid"),
// the trigger tooltip should just name the style, not a value the dropdown can't show
await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill')
await solid.click()
await expect(stylePanel.fillExtra).toHaveAttribute('aria-label', 'Fill')
})
test('selecting a style changes the style of the shapes', async ({
page,
stylePanel,
toolbar,
isMobile,
}) => {
const { blue } = stylePanel.colors
const { rectangle } = toolbar.tools
const { popoverRectangle } = toolbar.popOverTools
const { solid } = stylePanel.fill
if (isMobile) {
await toolbar.mobileStylesButton.click()
}
await blue.click()
if (isMobile) {
await toolbar.moreToolsButton.click()
await popoverRectangle.click()
} else {
await rectangle.click()
}
await page.mouse.click(150, 150)
const shapes1 = await page.evaluate(() => editor.getSelectedShapes())
expect(shapes1.every((s: any) => s.props.color === 'blue' && s.props.fill === 'none')).toBe(
true
)
if (isMobile) {
await toolbar.mobileStylesButton.click()
}
await solid.click()
await rectangle.click()
await page.mouse.click(250, 150)
await page.mouse.move(100, 100)
await page.mouse.down()
await page.mouse.move(400, 400)
await page.mouse.up()
const shapes2 = await page.evaluate(() => editor.getSelectedShapes())
expect(shapes2.every((s: any) => s.props.color === 'blue' && s.props.fill === 'solid')).toBe(
true
)
})
test('the correct styles are exposed for the selected tool', async ({
isMobile,
page,
toolbar,
stylePanel,
}) => {
const toolsStylesArr = [
{
name: 'tools.select',
styles: ['style.color', 'style.opacity', 'style.fill', 'style.dash', 'style.size'],
},
{ name: 'tools.more.frame', styles: ['style.opacity'] },
{
name: 'tools.text',
styles: ['style.size', 'style.color', 'style.opacity', 'style.font', 'style.align'],
},
{ name: 'tools.eraser', styles: [] },
]
for (const tool of toolsStylesArr) {
await test.step(`Check tool ${tool.name}`, async () => {
// mobile styles button is disabled for the eraser tool
if (tool.name === 'tools.eraser' && isMobile) return
if (tool.name.startsWith('tools.more')) await toolbar.moreToolsButton.click()
await page.getByTestId(tool.name).click()
if (isMobile) await toolbar.mobileStylesButton.click()
for (const style of stylePanel.stylesArray) {
const styleElement = page.getByTestId(style)
const isVisible = await styleElement.isVisible()
const isExpected = tool.styles.includes(style)
expect(isVisible).toBe(isExpected)
}
})
}
})
})
test.describe('mobile style panel', () => {
test.beforeEach(setupOrReset)
test('opens and closes as expected', async ({ isMobile, page, toolbar, stylePanel }) => {
test.skip(!isMobile, 'only run on mobile')
await test.step('clicking the mobile styles button', async () => {
await expect(stylePanel.getElement()).toBeHidden()
await toolbar.mobileStylesButton.click()
await expect(stylePanel.getElement()).toBeVisible()
await toolbar.mobileStylesButton.click()
await expect(stylePanel.getElement()).toBeHidden()
})
await test.step('clicking off the panel closes it', async () => {
await toolbar.mobileStylesButton.click()
await expect(stylePanel.getElement()).toBeVisible()
page.mouse.click(200, 200)
await expect(stylePanel.getElement()).toBeHidden()
})
})
test('style menu button is disabled for the eraser tool', async ({ isMobile, toolbar }) => {
test.skip(!isMobile, 'only run on mobile')
const { eraser } = toolbar.tools
await eraser.click()
await expect(toolbar.mobileStylesButton).toBeDisabled()
})
})

View File

@@ -0,0 +1,211 @@
import test, { expect } from '@playwright/test'
import { Editor } from 'tldraw'
import { setup } from '../shared-e2e'
declare const editor: Editor
// #8802: italic / cursive glyph ink (side bearings, slanted ascenders) spills past a text shape's
// advance box. On PNG export the shape is drawn into a <foreignObject> sized to that advance box,
// and the rasteriser clips the overflow at its edge — so the exported image loses the ink.
//
// This is self-relative so it's font-independent and stable across CI: the same glyphs are exported
// twice — once in an autosizing shape whose box hugs the text (so its foreignObject can clip), and
// once in a deliberately wide fixed-width box that has room to spare (so it can't clip). We scan the
// painted glyph width of each PNG. If the autosize export clips the ink, it comes out narrower than
// the wide one; when the export encloses the ink, the two match.
test.describe('text export ink', () => {
test.beforeEach(setup)
test('autosize export encloses italic glyph ink like a wide box does (#8802)', async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== 'chromium',
'export rasterisation compared on desktop chromium'
)
const { autoInkWidth, wideInkWidth } = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
const richText = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
}
// Same glyphs, two boxes: one that hugs the text (can clip) and one with room (can't).
ed.createShape({
id: 'shape:exAuto',
type: 'text',
x: 0,
y: 0,
props: { richText, font: 'serif', size: 'xl', autoSize: true, color: 'black' },
})
ed.createShape({
id: 'shape:exWide',
type: 'text',
x: 0,
y: 400,
props: { richText, font: 'serif', size: 'xl', autoSize: false, w: 800, color: 'black' },
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Scan a single shape's PNG export for its painted glyph bounding-box width (outline off, so
// the shadow halo doesn't pollute the scan).
async function inkWidth(id: string) {
const { url } = await ed.toImageDataUrl([id], {
format: 'png',
background: false,
scale: 2,
})
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = url
})
const cv = ed.getContainerDocument().createElement('canvas')
cv.width = img.naturalWidth
cv.height = img.naturalHeight
const ictx = cv.getContext('2d', { willReadFrequently: true })
ictx.drawImage(img, 0, 0)
const data = ictx.getImageData(0, 0, cv.width, cv.height).data
let minX = Infinity
let maxX = -Infinity
for (let y = 0; y < cv.height; y++) {
for (let x = 0; x < cv.width; x++) {
if (data[(y * cv.width + x) * 4 + 3] > 10) {
if (x < minX) minX = x
if (x > maxX) maxX = x
}
}
}
return isFinite(minX) ? maxX - minX + 1 : 0
}
const util = ed.getShapeUtil('text')
const prevOutline = util.options.showTextOutline
util.options.showTextOutline = false
try {
const autoInkWidth = await inkWidth('shape:exAuto')
const wideInkWidth = await inkWidth('shape:exWide')
return { autoInkWidth, wideInkWidth }
} finally {
util.options.showTextOutline = prevOutline
}
})
// The wide box can't clip, so its export shows the full glyph ink. The autosize export must
// enclose the same ink (to within anti-aliasing) rather than cutting it at the advance box.
expect(autoInkWidth).toBeGreaterThanOrEqual(wideInkWidth - 3)
})
test('png export of italic text is not clipped (visual) (#8802)', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'visual export snapshot on desktop chromium')
const dataUrl: string = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
ed.createShape({
id: 'shape:jiffy',
type: 'text',
x: 0,
y: 0,
props: {
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
},
font: 'serif',
size: 'xl',
autoSize: true,
color: 'black',
},
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Auto-trimmed export over the page background: the render padding is cropped back to the
// painted glyphs, so the golden is a tight image of the ink — the italic j / ff / y fully
// enclosed, on a solid page background.
const { url } = await ed.toImageDataUrl(['shape:jiffy'], {
format: 'png',
background: true,
scale: 2,
})
return url as string
})
// Golden of the exported glyphs: the italic j / ff / y ink must be fully present, not cut off
// at the advance box. Before the fix the descenders and slant were clipped at the edge.
expect(Buffer.from(dataUrl.split(',')[1], 'base64')).toMatchSnapshot('jiffy-italic-export.png')
})
test('svg export of italic text is not clipped (visual) (#8802)', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'chromium', 'visual export snapshot on desktop chromium')
const dataUrl: string = await page.evaluate(async () => {
const ed = editor as any
ed.selectAll().deleteShapes(ed.getSelectedShapeIds())
ed.createShape({
id: 'shape:jiffy',
type: 'text',
x: 0,
y: 0,
props: {
richText: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'jiffy', marks: [{ type: 'italic' }] }],
},
],
},
font: 'serif',
size: 'xl',
autoSize: true,
color: 'black',
},
})
await ed.fonts.loadRequiredFontsForCurrentPage()
await ed.getContainerDocument().fonts.ready
// Export as an SVG (auto-trimmed viewBox), then rasterise it the way a browser/viewer does:
// loading the SVG into an <img> is where Chrome clips each <foreignObject> to its rect, so this
// exercises the exact clipping path the fix targets. The padded foreignObject must render the
// full ink within the trimmed viewBox.
const { url, width, height } = await ed.toImageDataUrl(['shape:jiffy'], {
format: 'svg',
background: true,
})
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = url
})
const scale = 2
const cv = ed.getContainerDocument().createElement('canvas')
cv.width = Math.ceil(width * scale)
cv.height = Math.ceil(height * scale)
const c = cv.getContext('2d')!
c.drawImage(img, 0, 0, cv.width, cv.height)
return cv.toDataURL('image/png')
})
// Golden of the exported SVG rasterised: the italic j / ff / y ink must be fully present, matching
// the PNG export. Before the fix the foreignObject clipped the descenders and slant on rasterisation.
expect(Buffer.from(dataUrl.split(',')[1], 'base64')).toMatchSnapshot(
'jiffy-italic-svg-export.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,409 @@
import test, { Page, expect } from '@playwright/test'
import {
BoxModel,
Editor,
TLMeasureTextOpts,
TLMeasureTextSpanOpts,
TLNoteShape,
TLShapeId,
} from 'tldraw'
import { EndToEndApi } from '../../src/misc/EndToEndApi'
import { setupPage } from '../shared-e2e'
declare const tldrawApi: EndToEndApi
const measureTextOptions: TLMeasureTextOpts = {
fontFamily: 'var(--tl-font-draw)',
fontSize: 24,
lineHeight: 1.35,
fontWeight: 'normal',
fontStyle: 'normal',
padding: '0px',
maxWidth: null,
}
const measureTextSpansOptions: TLMeasureTextSpanOpts = {
fontFamily: 'var(--tl-font-draw)',
fontSize: 24,
lineHeight: 1.35,
fontWeight: 'normal',
fontStyle: 'normal',
padding: 0,
width: 100,
height: 1000,
overflow: 'wrap' as const,
textAlign: 'start' as 'start' | 'middle' | 'end',
}
function formatLines(spans: { box: BoxModel; text: string }[]) {
const lines = []
let currentLine: string[] | null = null
let currentLineTop = null
for (const span of spans) {
if (currentLineTop !== span.box.y) {
if (currentLine !== null) {
lines.push(currentLine)
}
currentLine = []
currentLineTop = span.box.y
}
currentLine!.push(span.text)
}
if (currentLine !== null) {
lines.push(currentLine)
}
return lines
}
declare const editor: Editor
let page: Page
test.describe('text measurement', () => {
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await setupPage(page)
})
test('measures text', async () => {
const { w, h } = await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(
async (options) => editor.textMeasure.measureText('testing', options),
measureTextOptions
)
// works on github actions
expect(w).toBeCloseTo(87, 0)
expect(h).toBeCloseTo(32.3984375, 0)
})
// The text-measurement tests below this point aren't super useful any
// more. They were added when we had a different approach to text SVG
// exports (trying to replicate browser decisions with our own code) to
// what we do now (letting the browser make those decisions then
// measuring the results).
//
// It's hard to write better tests here (e.g. ones where we actually
// look at the measured values) because the specifics of text layout
// vary from browser to browser. The ideal thing would be to replace
// these with visual regression tests for text SVG exports, but we don't
// have a way of doing visual regression testing right now.
test('should get a single text span', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('testing', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([['testing']])
})
test('should wrap a word when it has to', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('testing', { ...options, width: 50 }),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([['tes'], ['ting']])
})
test('should preserve whitespace at line breaks', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('testing testing', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([['testing', ' '], ['testing']])
})
test('should preserve whitespace at the end of wrapped lines', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('testing testing ', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([
['testing', ' '],
['testing', ' '],
])
})
test('preserves whitespace at the end of unwrapped lines', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) =>
editor.textMeasure.measureTextSpans('testing testing ', { ...options, width: 200 }),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([['testing', ' ', 'testing', ' ']])
})
test('preserves whitespace at the start of an unwrapped line', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) =>
editor.textMeasure.measureTextSpans(' testing testing', { ...options, width: 200 }),
measureTextSpansOptions
)
expect(formatLines(spans)[0]?.[0]).toEqual(' ')
})
test('should place starting whitespace on its own line if it has to', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans(' testing testing', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([[' '], ['testing', ' '], ['testing']])
})
test('should handle multiline text', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) =>
editor.textMeasure.measureTextSpans(' test\ning testing \n t', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([
[' ', 'test', '\n'],
['ing', ' '],
['testing', ' \n'],
[' ', 't'],
])
})
test('should break long strings of text', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) =>
editor.textMeasure.measureTextSpans('testingtestingtestingtestingtestingtesting', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([
['testingt'],
['estingt'],
['estingt'],
['estingt'],
['estingt'],
['esting'],
])
})
test('should return an empty array if the text is empty', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(async (options) => editor.textMeasure.measureTextSpans('', options), measureTextSpansOptions)
expect(formatLines(spans)).toEqual([])
})
test('should handle trailing newlines', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('hi\n\n\n', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([['hi', '\n'], [' \n'], [' \n'], [' ']])
})
test('should handle only newlines', async () => {
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(
async (options) => editor.textMeasure.measureTextSpans('\n\n\n', options),
measureTextSpansOptions
)
expect(formatLines(spans)).toEqual([[' \n'], [' \n'], [' \n'], [' ']])
})
test('should keep emoji grapheme clusters together', async () => {
// 👨‍👩‍👧 is several code points joined by zero-width joiners; it must be
// measured as one grapheme so no code points get dropped from the span text.
const family = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
const spans = await page.evaluate<
{ text: string; box: BoxModel }[],
typeof measureTextSpansOptions
>(async (options) => {
const familyEmoji = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
return editor.textMeasure.measureTextSpans(`a${familyEmoji}b`, { ...options, width: 200 })
}, measureTextSpansOptions)
expect(formatLines(spans).flat().join('')).toBe(`a${family}b`)
})
test('for auto-font-sizing shapes, should do normal font size for text that does not have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
richText: tldrawApi.toRichText('this is just some regular text'),
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(1)
})
test('for auto-font-sizing shapes, should auto-size text that have slightly long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
richText: tldrawApi.toRichText('Amsterdam'),
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(0.84375)
})
test('for auto-font-sizing shapes, should auto-size text that have long words', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
richText: tldrawApi.toRichText('this is a tentoonstelling'),
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(0.625)
})
test('for auto-font-sizing shapes, should wrap text that has words that are way too long', async () => {
const shape = await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
richText: tldrawApi.toRichText(
'a very long dutch word like ziekenhuisinrichtingsmaatschappij'
),
size: 'xl',
},
},
])
return editor.getShape(id) as TLNoteShape
})
expect(shape.props.fontSizeAdjustment).toEqual(0.4375)
})
test('should use custom renderMethod for text measurement', async () => {
const { w, h } = await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(
async (options) => {
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
},
measureTextOptions
)
// Assuming the custom render method affects the width and height
// Adjust these expected values based on how renderMethod is supposed to affect the output
// Works on github actions, annoyingly
expect(w).toBeGreaterThanOrEqual(165)
expect(w).toBeLessThanOrEqual(167)
expect(h).toBeCloseTo(32.390625, 0)
})
test('element should have no leftover properties', async () => {
const measure = page.locator('div.tl-text-measure')
await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(async (options) => {
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
}, measureTextOptions)
const firstStyle = (await measure.getAttribute('style')) ?? ''
expect(await measure.getAttribute('style')).toMatch(firstStyle)
await page.evaluate(() => {
const id = 'shape:testShape' as TLShapeId
editor.createShapes([
{
id,
type: 'note',
x: 0,
y: 0,
props: {
richText: tldrawApi.toRichText(
'a very long dutch word like ziekenhuisinrichtingsmaatschappij'
),
size: 'xl',
},
},
])
})
await page.evaluate<{ w: number; h: number }, typeof measureTextOptions>(async (options) => {
return editor.textMeasure.measureHtml(`<div><strong>HELLO WORLD</strong></div>`, options)
}, measureTextOptions)
expect(await page.locator('div.tl-text-measure').getAttribute('style')).toMatch(firstStyle)
})
})

Some files were not shown because too many files have changed in this diff Show More