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
161 lines
5.3 KiB
TypeScript
161 lines
5.3 KiB
TypeScript
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)
|
|
})
|
|
})
|