Files
upstream-tldraw/apps/examples/e2e/tests/test-rich-text-toolbar.spec.ts
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

563 lines
18 KiB
TypeScript

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
})
})