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
38
apps/examples/e2e/tests/README-PERFORMANCE.md
Normal 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
|
||||
```
|
||||
90
apps/examples/e2e/tests/export-mermaid-snapshots.spec.ts
Normal 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
|
||||
}
|
||||
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 49 KiB |
582
apps/examples/e2e/tests/export-snapshots-data.tsx
Normal 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 },
|
||||
]),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
1225
apps/examples/e2e/tests/export-snapshots.spec.tsx
Normal file
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 97 KiB |
328
apps/examples/e2e/tests/test-a11y-style-panel-navigation.spec.ts
Normal 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
|
||||
)
|
||||
})
|
||||
}
|
||||
20
apps/examples/e2e/tests/test-actions-menu.spec.ts
Normal 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
|
||||
// ...
|
||||
})
|
||||
21
apps/examples/e2e/tests/test-api.spec.ts
Normal 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
|
||||
})
|
||||
}
|
||||
})
|
||||
256
apps/examples/e2e/tests/test-camera.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
153
apps/examples/e2e/tests/test-canvas-events.spec.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
270
apps/examples/e2e/tests/test-clipboard-events.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
104
apps/examples/e2e/tests/test-clipboard.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
219
apps/examples/e2e/tests/test-commenting-a11y.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
298
apps/examples/e2e/tests/test-context-menu.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
134
apps/examples/e2e/tests/test-dialogs.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
298
apps/examples/e2e/tests/test-focus.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
248
apps/examples/e2e/tests/test-image-toolbar.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
891
apps/examples/e2e/tests/test-kbds.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
182
apps/examples/e2e/tests/test-licensing.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
44
apps/examples/e2e/tests/test-main-menu.spec.ts
Normal 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
|
||||
// ...
|
||||
})
|
||||
33
apps/examples/e2e/tests/test-navigation-panel.spec.ts
Normal 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
|
||||
// ...
|
||||
})
|
||||
261
apps/examples/e2e/tests/test-overlay-snapshots.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
685
apps/examples/e2e/tests/test-page-menu.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
57
apps/examples/e2e/tests/test-paste-text-measurement.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
163
apps/examples/e2e/tests/test-pointer-cancel.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
562
apps/examples/e2e/tests/test-rich-text-toolbar.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
65
apps/examples/e2e/tests/test-rich-text.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
44
apps/examples/e2e/tests/test-routes.spec.ts
Normal 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')
|
||||
})
|
||||
}
|
||||
})
|
||||
160
apps/examples/e2e/tests/test-rtl-text-alignment.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
158
apps/examples/e2e/tests/test-shapes.spec.ts
Normal 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')
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
126
apps/examples/e2e/tests/test-smoke.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
159
apps/examples/e2e/tests/test-style-panel.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
211
apps/examples/e2e/tests/test-text-export-ink.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
409
apps/examples/e2e/tests/test-text.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||