import assert from 'assert' import { rename, writeFile } from 'fs/promises' /* eslint-disable tldraw/no-internal-imports, react/no-string-refs */ import { Page, expect } from '@playwright/test' import { ReactElement } from 'react' import { DefaultColorStyle, DefaultDashStyle, DefaultFillStyle, DefaultFontStyle, Editor, GeoShapeGeoStyle, TLAsset, TLBindingCreate, TLShapePartial, degreesToRadians, mapObjectMapValues, mockUniqueId, toRichText, } from 'tldraw' import { TL, createDrawSegments, shapesFromJsx } from 'tldraw/src/test/test-jsx' import { EndToEndApi } from '../../src/misc/EndToEndApi' import test, { ApiFixture } from '../fixtures/fixtures' import { hardResetEditor, setup } from '../shared-e2e' import { convexDrawShape, frameContent, heyDrawShape, manAsset, manCrop, manCropAsCircle, richText, richTextForArrow, } from './export-snapshots-data' declare const editor: Editor declare const tldrawApi: EndToEndApi let nextNanoId = 0 mockUniqueId(() => `mock-${nextNanoId++}`) interface Snapshots { [name: string]: { [row: string]: { [testCase: string]: ReactElement } } } const snapshots: Snapshots = { 'Text rendering': { 'geo text': { 'leading line breaks': ( ), 'trailing line breaks': ( ), 'mixed RTL': ( ), 'overflowing text with small scale': ( ), 'overflowing text with large scale': ( ), 'rich text': , }, }, // A dedicated group (its own snapshot image) for RTL export alignment (#7720): `start` must // flush lines to the right and `end` to the left for RTL — matching the canvas, not the // reversed physical mapping that used to ship. The LTR pair is the mirror-image control. 'Text alignment': { rtl: { 'start (flush right)': ( ), 'end (flush left)': ( ), }, ltr: { 'start (flush left)': ( ), 'end (flush right)': ( ), }, }, Fills: Object.fromEntries( DefaultFillStyle.values.map((fill) => [ `fill=${fill}`, { geo: , arrow: ( ), draw: ( ), }, ]) ), Fonts: Object.fromEntries( DefaultFontStyle.values.map((font) => [ `font=${font}`, { geo: , arrow: ( ), note: , text: , }, ]) ), 'Geo shapes': Object.fromEntries( GeoShapeGeoStyle.values.map((geoStyle, y) => [ geoStyle, Object.fromEntries( DefaultDashStyle.values.map((dashStyle, x) => [ dashStyle, , ]) ), ]) ), // Directional geo shapes must mirror their geometry (not just their position) when flipped, // and the label must stay upright/unmirrored (#9373). These rows lock in the mirrored path // and the readable label for each flip combination across the SVG export. 'Geo flips': Object.fromEntries( ( [ 'rhombus', 'rhombus-2', 'trapezoid', 'triangle', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', ] as const ).map((geoStyle) => [ geoStyle, { none: ( ), flipX: ( ), flipY: ( ), flipXY: ( ), }, ]) ), Frames: { '': { empty: , labelled: , content: ( {frameContent} ), nested: ( {frameContent} {frameContent} ), }, 'label rotation': Object.fromEntries( [10, 70, 130, 190, 150, 310].map((rotation, i) => [ `${rotation}°`, {frameContent} , ]) ), }, Images: { Uncropped: { 'no asset': , asset: , flipX: , flipY: , flipXY: , rotated: , }, Cropped: { 'no asset': , asset: , flipX: , flipY: , flipXY: , withCircle: ( ), rotated: ( ), }, }, Bookmarks: { 'not rotated': { full: ( } /> ), long: ( } /> ), 'no image': ( } /> ), 'no favicon': ( } /> ), 'no meta': ( } /> ), empty: ( } /> ), }, 'rotated (note shadow)': { '30°': ( } /> ), '135°': ( } /> ), '255°': ( } /> ), }, }, 'Foreign objects': { 'HTML & CSS': { simple: ( ), pseudos: ( ), transforms: ( ), '@font-face': ( ), 'custom elements': (
  • Apples
  • Pears
  • Bananas

A common, sweet, crunchy fruit, usually green or yellow in color.

A fairly common, sweet, usually green fruit, usually softer than Apples.

A long, curved, yellow fruit, with a fairly gentle flavor.

`} css={` #self { background-color: lightcoral; } #self custom-element { background-color: plum; } #self .selected { font-size: 20px; } `} /> ), }, Embeds: { Video: , Image: , Background: ( ), }, }, Arrows: { '': { Arrow1: ( ), Arrow2: ( ), Arrow3: ( ), Arrow4: ( ), Arrow5: ( ), }, }, Regressions: { '#5020': { 'scaled text within a frame': ( ), }, '#6391': { 'Circles at 45deg from each other': ( <> ), }, }, 'Weird elbow arrows': { 'Small segments, contained': { A: ( <> ), B: ( <> ), 'Exact match': ( <> ), C: ( <> ), D: ( <> ), }, 'Draw shapes': { 'Hey, near': ( <> {heyDrawShape} ), 'Hey, far': ( <> {heyDrawShape} ), 'Hey, within': ( <> {heyDrawShape} ), 'Convex, far': ( <> {convexDrawShape} ), 'Convex, edge': ( <> {convexDrawShape} ), }, 'Self-bound': { 'Draw shape,\nsame-edge': ( <> {convexDrawShape} ), 'Box,\nsame edge': ( <> ), 'Triangle,\nopposites': ( <> ), Outside: ( <> ), }, 'Regressions 1': { 'ENG-3332 ✅': ( <> ), 'ENG-3333 🚫': ( <> ), 'ENG-3335 🚫': ( <> ), 'ENG-3336 ✅': ( // this one isn't actually an elbow arrow issue but was introduced by elbow arrows // work so is still included here. <> ), 'ENG-3342 ✅': ( <> ), }, 'Regressions 2': { 'ENG-3366 ✅': ( <> ), 'ENG-3371 ✅': ( <> ), 'ENG-3375 ✅': ( <> {heyDrawShape} ), }, }, } interface SnapshotWithoutJsx { [row: string]: { [testCase: string]: { shapes: TLShapePartial[]; assets: TLAsset[]; bindings: TLBindingCreate[] } } } test.describe('Export snapshots', () => { const snapshotsToTest = Object.entries(snapshots) test.beforeEach(async ({ page, context, api }) => { const url = page.url() if (!url.includes('end-to-end')) { await setup({ page, context } as any) } else { await hardResetEditor(page) } // These snapshots lay shapes out by their measured bounds, and text is measured from // the loaded font - so load the fonts before anything is measured. Otherwise a shape // can be measured with fallback-font metrics, which shifts the layout and the export's // size and makes the screenshot diff flaky. await api.preloadFonts() }) for (const [name, snapshotWithJsx] of snapshotsToTest) { for (const colorScheme of ['light', 'dark'] as const) { test(`${name} (${colorScheme})`, async ({ page, api }) => { nextNanoId = 0 const snapshot: SnapshotWithoutJsx = mapObjectMapValues(snapshotWithJsx, (i, row) => mapObjectMapValues(row, (j, testCase) => { const { shapes, assets, bindings } = shapesFromJsx(testCase, `${i}-${j}`) return { shapes, assets, bindings } }) ) await page.evaluate( ({ colorScheme, name, snapshot, }: { colorScheme: 'light' | 'dark' name: string snapshot: SnapshotWithoutJsx }) => { editor.user.updateUserPreferences({ colorScheme }) editor .updateInstanceState({ exportBackground: true }) .selectAll() .deleteShapes(editor.getSelectedShapeIds()) const titleId = tldrawApi.createShapeId() editor.createShape({ id: titleId, type: 'text', x: 0, y: 0, props: { richText: tldrawApi.toRichText(name), font: 'mono', size: 'xl' }, }) let y = editor.getShapePageBounds(titleId)!.maxY + 30 for (const [rowName, testCases] of Object.entries(snapshot)) { const rowTitleId = tldrawApi.createShapeId() editor.createShape({ id: rowTitleId, type: 'text', x: 0, y, props: { richText: tldrawApi.toRichText(rowName), font: 'mono', size: 'm' }, }) y = editor.getShapePageBounds(rowTitleId)!.maxY + 20 let x = 0 let bottom = y for (const [testCaseName, { shapes, assets, bindings }] of Object.entries( testCases )) { const testCaseTitleId = tldrawApi.createShapeId() editor.createShape({ id: testCaseTitleId, type: 'text', x, y, props: { richText: tldrawApi.toRichText(testCaseName), font: 'mono', size: 's', }, }) const testCastTitleBounds = editor.getShapePageBounds(testCaseTitleId)! editor.createAssets(assets) editor.createShapes(shapes) editor.createBindings(bindings) const topLevelShapeIds = shapes .filter((shape) => !shape.parentId) .map((shape) => shape.id) editor.setSelectedShapes(topLevelShapeIds) let bounds = editor.getSelectionPageBounds()! editor.nudgeShapes(topLevelShapeIds, { x: x - bounds.minX, y: testCastTitleBounds.maxY + 25 - bounds.minY, }) bounds = editor.getSelectionPageBounds()! x = Math.max(testCastTitleBounds.maxX, bounds.maxX) + 60 bottom = Math.max(bottom, bounds.maxY) } y = bottom + 40 } tldrawApi.markAllArrowBindings() editor.selectAll() }, { colorScheme, name, snapshot } as any ) 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', ` `, '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, }) }) await api.exportAsSvg() await downloadAndSnapshot } })