diff --git a/.github/workflows/call-e2e-all-tests.yml b/.github/workflows/call-e2e-all-tests.yml index 4260ffea4bb..d1e734362dc 100644 --- a/.github/workflows/call-e2e-all-tests.yml +++ b/.github/workflows/call-e2e-all-tests.yml @@ -154,6 +154,19 @@ jobs: browser: ${{ matrix.browser }} editor-mode: 'rich-text-with-collab' + collab-v2-linux: + needs: [build-ubuntu] + strategy: + matrix: + node-version: [24.x] + browser: ['chromium', 'firefox'] + uses: ./.github/workflows/call-e2e-test.yml + with: + os: 'ubuntu-latest' + node-version: ${{ matrix.node-version }} + browser: ${{ matrix.browser }} + editor-mode: 'rich-text-with-collab-v2' + collab-windows: needs: [build-windows] strategy: @@ -198,19 +211,3 @@ jobs: node-version: ${{ matrix.node-version }} browser: ${{ matrix.browser }} editor-mode: ${{ matrix.editor-mode }} - flaky: - # There aren't currently any flaky tests - if: false - needs: [build-ubuntu] - strategy: - matrix: - node-version: [24.x] - browser: ['chromium', 'firefox'] - editor-mode: ['rich-text', 'plain-text', 'rich-text-with-collab'] - uses: ./.github/workflows/call-e2e-test.yml - with: - os: 'ubuntu-latest' - flaky: true - node-version: ${{ matrix.node-version }} - browser: ${{ matrix.browser }} - editor-mode: ${{ matrix.editor-mode }} diff --git a/.github/workflows/call-e2e-test.yml b/.github/workflows/call-e2e-test.yml index 156f4ba382d..7ce9d8d4105 100644 --- a/.github/workflows/call-e2e-test.yml +++ b/.github/workflows/call-e2e-test.yml @@ -9,7 +9,6 @@ on: browser: {required: true, type: string} editor-mode: {required: true, type: string} prod: {required: false, type: boolean} - flaky: {required: false, type: boolean} # Defaults to false: dont run flaky fail-on-cache-miss: {required: false, type: boolean, default: true} permissions: {} @@ -17,7 +16,6 @@ permissions: {} jobs: e2e-test: runs-on: ${{ inputs.os }} - continue-on-error: ${{ inputs.flaky }} if: (inputs.browser != 'webkit' || inputs.os == 'macos-latest') env: CI: true @@ -39,19 +37,19 @@ jobs: node-version: ${{ inputs.node-version }} fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }} - name: Run tests - if: inputs.editor-mode != 'rich-text-with-collab' - run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} "@flaky"${{ inputs.flaky && ' --pass-with-no-tests' || '' }} + if: ${{ !startsWith(inputs.editor-mode, 'rich-text-with-collab') }} + run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} - name: Run collab tests - if: inputs.editor-mode == 'rich-text-with-collab' + if: ${{ startsWith(inputs.editor-mode, 'rich-text-with-collab') }} shell: bash run: | pnpm exec concurrently -k -s first \ "pnpm run collab" \ - "pnpm run test-e2e-collab-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} @flaky${{ inputs.flaky && ' --pass-with-no-tests' || '' }}" + "pnpm run test-e2e-collab-${{ inputs.editor-mode == 'rich-text-with-collab-v2' && 'v2-' || '' }}${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }}" - name: Upload Artifacts if: failure() uses: actions/upload-artifact@v7 with: - name: Test Results ${{ inputs.os }}-${{ inputs.browser }}-${{ inputs.editor-mode }}-${{ inputs.prod && 'prod' || 'dev' }}-${{ inputs.node-version }}-${{ inputs.flaky && 'flaky' || ''}}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} + name: Test Results ${{ inputs.os }}-${{ inputs.browser }}-${{ inputs.editor-mode }}-${{ inputs.prod && 'prod' || 'dev' }}-${{ inputs.node-version }}-${{ github.sha }}-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ env.test_results_path }} retention-days: 7 diff --git a/package.json b/package.json index 73d43c125a6..366eb64f66c 100644 --- a/package.json +++ b/package.json @@ -62,14 +62,14 @@ "test-e2e-collab-v2-webkit": "cross-env E2E_BROWSER=webkit E2E_EDITOR_MODE=rich-text-with-collab-v2 playwright test --project=\"webkit\"", "test-e2e-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 playwright test --project=\"chromium\"", "test-e2e-collab-prod-chromium": "cross-env E2E_BROWSER=chromium E2E_PORT=4000 E2E_EDITOR_MODE=rich-text-with-collab playwright test --project=\"chromium\"", - "test-e2e-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-chromium --grep-invert \"@flaky\"", - "test-e2e-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-firefox --grep-invert \"@flaky\"", - "test-e2e-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-webkit --grep-invert \"@flaky\"", - "test-e2e-collab-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-chromium --grep-invert '@flaky' {@}\"", - "test-e2e-collab-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-firefox --grep-invert '@flaky' {@}\"", - "test-e2e-collab-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-webkit --grep-invert '@flaky' {@}\"", - "test-e2e-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 pnpm run test-e2e-prod-chromium --grep-invert \"@flaky\"", - "test-e2e-collab-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-prod-chromium --grep-invert '@flaky' {@}\"", + "test-e2e-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-chromium", + "test-e2e-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-firefox", + "test-e2e-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 pnpm run test-e2e-webkit", + "test-e2e-collab-ci-chromium": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-chromium {@}\"", + "test-e2e-collab-ci-firefox": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-firefox {@}\"", + "test-e2e-collab-ci-webkit": "pnpm run prepare-ci && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-webkit {@}\"", + "test-e2e-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 pnpm run test-e2e-prod-chromium", + "test-e2e-collab-prod-ci-chromium": "pnpm run prepare-ci-prod && cross-env E2E_PORT=4000 concurrently -k -s \"first\" \"pnpm run collab\" -P \"pnpm run test-e2e-collab-prod-chromium {@}\"", "debug-run-all": "npm-run-all 'debug-test-e2e-* -- {1}' --", "run-all": "npm-run-all 'test-e2e-* -- {1}' --", "debug-test-e2e": "cross-env playwright test --debug", diff --git a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs index 89815224874..8023c9c47c6 100644 --- a/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/ClearFormatting.spec.mjs @@ -28,7 +28,7 @@ import { selectFromBackgroundColorPicker, selectFromColorPicker, test, - waitForSelector, + waitForTypeaheadMenuOption, } from '../utils/index.mjs'; test.describe('Clear All Formatting', () => { @@ -131,14 +131,9 @@ test.describe('Clear All Formatting', () => { await page.keyboard.type('@Luke'); - // Wait until "Luke Skywalker" is the *highlighted* option, not merely - // present: while "@Luke" is still being typed, the partial query "@Lu" - // also matches "Agent Kallus" (kal**lu**s), which sorts earlier in the - // list and is highlighted first, so pressing Enter too early selects it. - await waitForSelector( - page, - '#typeahead-menu ul li[aria-selected="true"]:has-text("Luke Skywalker")', - ); + // Wait until "Luke Skywalker" is the *highlighted* option before pressing + // Enter; see waitForTypeaheadMenuOption for why merely-present is racy. + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, html` diff --git a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs index 19f9480854f..c7563abdf8f 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeActionMenu.spec.mjs @@ -208,7 +208,13 @@ test.describe('CodeActionMenu', () => { await mouseMoveToSelector(page, 'code.PlaygroundEditorTheme__code'); await click(page, 'button[aria-label=prettier]'); - await page.waitForTimeout(3000); + // Prettier loads and formats asynchronously; wait for the reformatted + // result instead of a fixed timeout. Formatting turns the single input + // line into multiple lines, so a
inside the code block is a reliable + // "prettier finished" signal (attached, not visible:
has no box). + await waitForSelector(page, 'code.PlaygroundEditorTheme__code br', { + state: 'attached', + }); await assertHTML( page, @@ -254,7 +260,9 @@ test.describe('CodeActionMenu', () => { await mouseMoveToSelector(page, 'code.PlaygroundEditorTheme__code'); await click(page, 'button[aria-label=prettier]'); - await page.waitForTimeout(3000); + // Prettier reports invalid syntax asynchronously; wait for the error badge + // to appear instead of a fixed timeout (the assertions below do not retry). + await waitForSelector(page, 'i.format.prettier-error'); expect(await page.$('i.format.prettier-error')).toBeTruthy(); diff --git a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs index 8fb5c12ed7c..ee7c4e0bed2 100644 --- a/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Collaboration.spec.mjs @@ -10,18 +10,20 @@ import {expect} from '@playwright/test'; import { moveLeft, + moveToLineBeginning, + moveToLineEnd, selectCharacters, toggleBold, undo, } from '../keyboardShortcuts/index.mjs'; import { + advanceHistoryClock, assertHTML, assertSelection, click, focusEditor, html, initialize, - sleep, test, } from '../utils/index.mjs'; @@ -46,7 +48,7 @@ test.describe('Collaboration', () => { await page.keyboard.press('Enter'); await page.keyboard.press('Enter'); await page.keyboard.type('world'); - await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await advanceHistoryClock(page); await page.keyboard.press('ArrowUp'); await page.keyboard.type('hello world again'); @@ -229,17 +231,20 @@ test.describe('Collaboration', () => { await focusEditor(page); await page.keyboard.type('Line 1'); await page.keyboard.press('Enter'); - await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await advanceHistoryClock(page); await page.keyboard.type('This is a test. '); - // Right collaborator types at the end of paragraph 2 - await sleep(1050); - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - await page.keyboard.press('ArrowDown'); // Move caret to end of paragraph 2 - await page.keyboard.press('ArrowDown'); + // Right collaborator types at the end of paragraph 2. Click into that + // paragraph to place the caret — a real remote user positions the cursor + // with a click — then move to the line end. Relying on a default caret + // position + ArrowDown is fragile: once content has synced in, the idle + // frame's selection is not guaranteed to start at the top of the document. + const rightFrame = page.frameLocator('iframe[name="right"]'); + await expect( + rightFrame.locator('[data-lexical-editor="true"]'), + ).toContainText('This is a test.'); + await rightFrame.locator('p').nth(1).click(); + await moveToLineEnd(page); await page.keyboard.type('Word'); await assertHTML( @@ -311,7 +316,7 @@ test.describe('Collaboration', () => {

`, ); - await sleep(1050); + await advanceHistoryClock(page); await toggleBold(page); await page.keyboard.type('bold'); @@ -329,12 +334,11 @@ test.describe('Collaboration', () => { `, ); - // Right collaborator types at the end of the paragraph. - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - await page.keyboard.press('ArrowDown', {delay: 50}); // Move caret to end of paragraph + // Right collaborator types at the end of the paragraph. Click to place the + // caret (real remote user positioning) then move to the line end, rather + // than relying on a default caret position + ArrowDown. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineEnd(page); await page.keyboard.type('BOLD'); await assertHTML( @@ -351,9 +355,14 @@ test.describe('Collaboration', () => { `, ); - // Left collaborator undoes their bold text. - await sleep(1050); - await page.frameLocator('iframe[name="left"]').getByLabel('Undo').click(); + // Left collaborator undoes their bold text. The assertHTML above already + // confirmed both frames converged, so wait for the undo control to be + // actionable instead of sleeping. + const undoButton = page + .frameLocator('iframe[name="left"]') + .getByLabel('Undo'); + await expect(undoButton).toBeEnabled(); + await undoButton.click(); // The undo also removed bold the text node from YJS. // Check that the dangling text from right user was merged into the preceding text node. @@ -396,7 +405,7 @@ test.describe('Collaboration', () => { await focusEditor(page); await page.keyboard.type('normal bold'); - await sleep(1050); + await advanceHistoryClock(page); await selectCharacters(page, 'left', 'bold'.length); await toggleBold(page); @@ -414,12 +423,12 @@ test.describe('Collaboration', () => { `, ); - // Right collaborator types in the middle of the bold word. - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - await page.keyboard.press('ArrowDown', {delay: 50}); + // Right collaborator types in the middle of the bold word. Click to place + // the caret (real remote-user positioning), then step left from the line + // end — relying on a default caret position + ArrowDown is fragile because + // the idle frame's synced selection isn't guaranteed to start at the top. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineEnd(page); await page.keyboard.press('ArrowLeft'); await page.keyboard.press('ArrowLeft'); await page.keyboard.type('BOLD'); @@ -481,17 +490,16 @@ test.describe('Collaboration', () => { `, ); - // Collaboration should still work. - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - // TODO this is a workaround for Firefox so that the - // selection picks up the text format + // Collaboration should still work. Click into the paragraph and move to the + // line end, rather than relying on a default caret position + ArrowDown. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineEnd(page); + // Firefox doesn't carry the bold format to a caret at the very end of the + // bold run, so re-enter the run and return so the appended text inherits it. if (browserName === 'firefox') { - await page.keyboard.press('ArrowLeft', {delay: 50}); + await page.keyboard.press('ArrowLeft'); + await page.keyboard.press('ArrowRight'); } - await page.keyboard.press('ArrowDown', {delay: 50}); await page.keyboard.type(' text'); await assertHTML( @@ -521,7 +529,7 @@ test.describe('Collaboration', () => { await focusEditor(page); await page.keyboard.type('Check out the website!'); - await sleep(1050); + await advanceHistoryClock(page); await page.keyboard.press('ArrowLeft'); await selectCharacters(page, 'left', 'website'.length); await page @@ -546,12 +554,11 @@ test.describe('Collaboration', () => { `, ); - // Right collaborator adds some text. - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - await page.keyboard.press('ArrowDown', {delay: 50}); + // Right collaborator adds some text just before the trailing "!". Click to + // place the caret (real remote user positioning), move to the line end, + // then step left over "!", rather than relying on a default caret position. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineEnd(page); await page.keyboard.press('ArrowLeft'); await page.keyboard.type(' now'); @@ -616,12 +623,11 @@ test.describe('Collaboration', () => { `, ); - // Collaboration should still work. - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); - await page.keyboard.press('ArrowDown', {delay: 50}); + // Collaboration should still work. Click into the paragraph and move to the + // line end to append after "now!", rather than relying on a default caret + // position + ArrowDown. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineEnd(page); await page.keyboard.type(' Check it out.'); await assertHTML( @@ -674,7 +680,7 @@ test.describe('Collaboration', () => { ); // Left collaborator deletes A, right deletes B. - await sleep(1050); + await advanceHistoryClock(page); await page.keyboard.press('Delete'); await assertHTML( page, @@ -689,10 +695,11 @@ test.describe('Collaboration', () => {

`, ); - await page - .frameLocator('iframe[name="right"]') - .locator('[data-lexical-editor="true"]') - .focus(); + // Right collaborator deletes "B". Click into the paragraph and move to the + // line start so forward-Delete removes the leading bold "B", rather than + // relying on a default caret position. + await page.frameLocator('iframe[name="right"]').locator('p').click(); + await moveToLineBeginning(page); await page.keyboard.press('Delete'); await assertHTML( diff --git a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs index 5c44a37237d..b326c12db06 100644 --- a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs @@ -27,6 +27,7 @@ import { keyUpCtrlOrMeta, test, waitForSelector, + waitForTypeaheadMenuOption, } from '../utils/index.mjs'; test.use({launchOptions: {slowMo: 50}}); @@ -644,7 +645,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('@Luke'); - await waitForSelector(page, '#typeahead-menu ul li'); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -754,7 +755,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('@Luke'); - await waitForSelector(page, '#typeahead-menu ul li'); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -861,7 +862,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('@Luke'); - await waitForSelector(page, '#typeahead-menu ul li'); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); const client = await page.context().newCDPSession(page); @@ -1284,7 +1285,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); await page.keyboard.type('@Luke'); - await waitForSelector(page, '#typeahead-menu ul li'); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); const client = await page.context().newCDPSession(page); diff --git a/packages/lexical-playground/__tests__/e2e/File.spec.mjs b/packages/lexical-playground/__tests__/e2e/File.spec.mjs index 5ee04b6cb3e..224c66d57e5 100644 --- a/packages/lexical-playground/__tests__/e2e/File.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/File.spec.mjs @@ -15,7 +15,6 @@ import { initialize, insertUploadImage, IS_COLLAB_V2, - sleep, test, waitForSelector, } from '../utils/index.mjs'; @@ -100,8 +99,10 @@ test.describe('File', () => { fileChooser.setFiles([filePath]); }); await click(page, '.action-button.import'); - await sleep(200); + // The import reads and parses the file asynchronously; assertHTML retries + // until the imported document replaces the empty editor, so no fixed wait + // is needed. await assertHTML(page, expectedHtml); }); }); diff --git a/packages/lexical-playground/__tests__/e2e/History.spec.mjs b/packages/lexical-playground/__tests__/e2e/History.spec.mjs index 3242893b94b..7a7d92efe8c 100644 --- a/packages/lexical-playground/__tests__/e2e/History.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/History.spec.mjs @@ -14,13 +14,13 @@ import { undo, } from '../keyboardShortcuts/index.mjs'; import { + advanceHistoryClock, assertHTML, assertSelection, enableCompositionKeyEvents, focusEditor, html, initialize, - sleep, test, } from '../utils/index.mjs'; @@ -34,7 +34,7 @@ test.describe('History', () => { test.skip(isCollab); await page.focus('div[contenteditable="true"]'); await page.keyboard.type('hello'); - await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await advanceHistoryClock(page); await page.keyboard.type(' world'); await page.keyboard.press('Enter'); await page.keyboard.type('hello world again'); @@ -553,11 +553,11 @@ test.describe('History - IME', () => { text: 'すし', }); - await sleep(1050); // default merge interval is 1000, add 50ms as overhead due to CI latency. + await advanceHistoryClock(page); await page.keyboard.type(' '); - await sleep(1050); + await advanceHistoryClock(page); // await page.keyboard.imeSetComposition('m', 1, 1); await client.send('Input.imeSetComposition', { @@ -578,7 +578,7 @@ test.describe('History - IME', () => { text: 'もj', }); - await sleep(1050); + await advanceHistoryClock(page); // await page.keyboard.imeSetComposition('もじ', 2, 2); await client.send('Input.imeSetComposition', { @@ -742,7 +742,7 @@ test.describe('History - IME', () => { text: 'a', }); - await sleep(1050); + await advanceHistoryClock(page); await client.send('Input.imeSetComposition', { selectionStart: 1, @@ -825,7 +825,7 @@ test.describe('History - IME', () => { text: 'a', }); - await sleep(1050); + await advanceHistoryClock(page); await client.send('Input.imeSetComposition', { selectionStart: 2, @@ -901,7 +901,7 @@ test.describe('History - IME', () => { await client.send('Input.insertText', { text: 'ab', }); - await sleep(1050); + await advanceHistoryClock(page); await page.keyboard.down('Shift'); await moveLeft(page, 1); diff --git a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs index 0769b471a57..f586f590870 100644 --- a/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Mentions.spec.mjs @@ -24,6 +24,7 @@ import { pasteFromClipboard, test, waitForSelector, + waitForTypeaheadMenuOption, } from '../utils/index.mjs'; test.describe('Mentions', () => { @@ -39,10 +40,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -114,10 +112,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -209,10 +204,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -285,10 +277,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -361,10 +350,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -458,10 +444,7 @@ test.describe('Mentions', () => { await page.keyboard.type('@Luke'); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await assertHTML( page, @@ -534,10 +517,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -546,10 +526,7 @@ test.describe('Mentions', () => { await page.keyboard.type('@Luke'); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(1)'); @@ -558,10 +535,7 @@ test.describe('Mentions', () => { await page.keyboard.type('@Luke'); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(3)'); @@ -570,10 +544,7 @@ test.describe('Mentions', () => { await page.keyboard.type('@Luke'); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention:nth-child(5)'); @@ -867,10 +838,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); @@ -947,10 +915,7 @@ test.describe('Mentions', () => { focusPath: [0, 0, 0], }); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await waitForSelector(page, '.mention'); diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index 88ed8443c8d..e48d340ada5 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -53,6 +53,7 @@ import { selectFromFormatDropdown, sleep, test, + waitForSelector, YOUTUBE_SAMPLE_URL, } from '../utils/index.mjs'; @@ -538,7 +539,10 @@ test.describe('Selection', () => { await pasteFromClipboard(page, { 'text/html': `link`, }); - await sleep(3000); + // Paste inserts the link and places the caret after it in a single update, + // so wait for the link to be reconciled rather than sleeping a fixed time + // before asserting the (non-retrying) selection. + await waitForSelector(page, 'a[href="https://test.com"]'); await assertSelection(page, { anchorOffset: 4, anchorPath: [0, 1, 0, 0], diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index dd4e9ec276c..6d2d3f504fd 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -22,6 +22,7 @@ import { undo, } from '../keyboardShortcuts/index.mjs'; import { + advanceHistoryClock, assertSelection, assertTableHTML as assertHTML, assertTableSelectionCoordinates, @@ -165,17 +166,18 @@ test.describe('Tables', () => { window.getSelection().setBaseAndExtent(col, 0, col, 0); }); - // Allow Lexical to process the selection change. - await sleep(50); - // The DOM caret must not be left inside the / region // (the reconciler should have written it back to the resolved cell). - const domAnchorNodeName = await evaluate( - page, - () => window.getSelection().anchorNode?.nodeName ?? null, - ); - expect(domAnchorNodeName).not.toBe('COL'); - expect(domAnchorNodeName).not.toBe('COLGROUP'); + // Poll for the selectionchange -> reconcile round-trip instead of sleeping + // a fixed time, which can be too short under load. + await expect + .poll(() => + evaluate( + page, + () => window.getSelection().anchorNode?.nodeName ?? null, + ), + ) + .not.toMatch(/^COL(GROUP)?$/); // Typing should land in the first cell, not extend "last". await page.keyboard.type('X'); @@ -7416,10 +7418,10 @@ test.describe('Tables', () => { false, false, ); - // undo is used so we need to wait for history - await sleep(1050); - - await sleep(1050); + // undo is used below, so force a new undo group here. Mode-agnostic: + // freezes the @lexical/history clock locally, or resets the Yjs + // UndoManager capture window in collab. + await advanceHistoryClock(page); await withExclusiveClipboardAccess(async () => { const clipboard = await copyToClipboard(page); diff --git a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs index 6ebe258ffb5..b1aced44d87 100644 --- a/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/379-backspace-with-mentions.spec.mjs @@ -14,7 +14,7 @@ import { html, initialize, test, - waitForSelector, + waitForTypeaheadMenuOption, } from '../utils/index.mjs'; test.describe('Regression test #379', () => { @@ -24,10 +24,7 @@ test.describe('Regression test #379', () => { }) => { await focusEditor(page); await page.keyboard.type('@Luke'); - await waitForSelector( - page, - '#typeahead-menu ul li:has-text("Luke Skywalker")', - ); + await waitForTypeaheadMenuOption(page, 'Luke Skywalker'); await page.keyboard.press('Enter'); await assertHTML( page, diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 9269a892214..02c2362d513 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -704,6 +704,67 @@ export async function sleep(delay) { await new Promise(resolve => setTimeout(resolve, delay)); } +/** + * Force a new undo group (a "merge boundary") deterministically — a drop-in + * replacement for `sleep(mergeWindow + overhead)`. Works in both editor modes + * and picks the right mechanism automatically: + * + * - **Local history** (`@lexical/history`): coalesces consecutive same-type + * edits while `now() < prevChangeTime + delay`. We drive the extension's + * `now` output signal directly — the lever called out in the task — freezing + * it `delay + overheadMs` past its current value. The next edit is then + * guaranteed to exceed the merge window (new boundary), while later edits + * observe a fixed time so genuinely fast edits still coalesce as in + * production. History reads `now` via `peek()`, so reassigning the signal + * neither re-registers history nor disturbs in-progress merge bookkeeping. + * - **Collab** (Yjs `UndoManager`, which `@lexical/history` is disabled in + * favor of): the manager coalesces changes within its own `captureTimeout` + * using `Date.now()`, which isn't injectable. Its public `stopCapturing()` + * resets the window (`lastChange = 0`) so the next change starts a fresh + * stack item — the deterministic equivalent of advancing past the window. + * The manager is published on the editor by `useYjsUndoManager`; see + * `Symbol.for('@lexical/yjs/UndoManager')`. + * + * Both replace wall-clock sleeps, which are slow and, under CI load, flaky: a + * timer that fires late can coalesce edits meant to stay separate (or vice + * versa). + * + * @param {import('@playwright/test').Page} page + * @param {number} [overheadMs] slack added past the local-history `delay`, + * mirroring the old `sleep(delay + 50)` convention. + */ +export async function advanceHistoryClock(page, overheadMs = 50) { + await evaluate( + page, + overhead => { + const editor = document.querySelector( + '[data-lexical-editor="true"]', + ).__lexicalEditor; + // Collab: reset the Yjs UndoManager's capture window. + const undoManager = editor[Symbol.for('@lexical/yjs/UndoManager')]; + if (undoManager) { + undoManager.stopCapturing(); + return; + } + // Local history: freeze `now` past the merge delay. + const builder = editor[Symbol.for('@lexical/extension/LexicalBuilder')]; + const output = builder?.extensionNameMap.get('@lexical/history/History') + ?.state?.output; + if (output && !output.disabled.value) { + const frozen = output.now.peek()() + output.delay.peek() + overhead; + output.now.value = () => frozen; + return; + } + throw new Error( + 'advanceHistoryClock: no active undo mechanism found on the editor ' + + '(expected the Yjs UndoManager in collab, or an enabled ' + + '@lexical/history extension otherwise)', + ); + }, + overheadMs, + ); +} + // Fair time for the browser to process a newly inserted image export async function sleepInsertImage(count = 1) { return await sleep(1000 * count); @@ -730,6 +791,33 @@ export async function waitForSelector(page, selector, options) { await getPageOrFrame(page).waitForSelector(selector, options); } +/** + * Wait until `optionText` is the *highlighted* (aria-selected) option in the + * typeahead / mentions menu, so a subsequent `Enter` deterministically commits + * it. + * + * The mentions lookup is asynchronous and incremental: while e.g. "@Luke" is + * being typed, the partial query "Lu" also matches options that sort earlier — + * the "Lu" results list "Agent Kallus" (kal**lu**s) at index 0 and only list + * "Luke Skywalker" at index 2. Those intermediate result sets resolve on their + * own timers, so waiting merely for the option *text* to be present and then + * pressing Enter (which commits the highlighted index 0) is racy: under load + * the Enter can land while an intermediate list is showing and commit the wrong + * option. Waiting for the option to be highlighted is deterministic because the + * settled list always highlights the intended option at index 0. + * + * @param {import('@playwright/test').Page} page + * @param {string} optionText + * @param {Parameters[1]} [options] + */ +export async function waitForTypeaheadMenuOption(page, optionText, options) { + await waitForSelector( + page, + `#typeahead-menu ul li[aria-selected="true"]:has-text("${optionText}")`, + options, + ); +} + export function locate(page, selector) { return getPageOrFrame(page).locator(selector); } diff --git a/packages/lexical-playground/scripts/build-space-dot-font.py b/packages/lexical-playground/scripts/build-space-dot-font.py new file mode 100644 index 00000000000..fe954f256a2 --- /dev/null +++ b/packages/lexical-playground/scripts/build-space-dot-font.py @@ -0,0 +1,129 @@ +#!/usr/bin/env -S uv run --script +# +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +# /// script +# requires-python = ">=3.11" +# dependencies = ["fonttools", "brotli"] +# /// + +# Regenerates the inline WOFF2 used by `VisibleNonPrintingExtension` for the +# space marker. The font has exactly one visible glyph: a round dot (a +# middot) of diameter 120 units, centered horizontally in the `U+0020` +# advance and sitting on the lowercase center line of a 1000 UPM em. Embedded +# as a base64 data URI in `PlaygroundEditorTheme.css` (`@font-face` block, +# see the comment above the inline `src`). Run this from the repo root with +# `uv` (auto-installs `fonttools` and `brotli`): +# +# uv run packages/lexical-playground/scripts/build-space-dot-font.py +# +# The script prints the WOFF2 byte size and the base64 payload to paste into +# the `@font-face` `src` URL. +# +# Note: WOFF2 byte size and payload may shift slightly across fontTools / +# brotli versions for the same glyph input — commit the new base64 (and the +# byte size in the CSS comment) when you regenerate. + +import base64 +import math +import os +import sys + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTFont + +UPM = 1000 +ADVANCE = 260 +DOT_DIAMETER = 120 +# Center the dot in the advance so it sits in the middle of the rendered +# space instead of hugging the left edge. +DOT_CX = ADVANCE // 2 +DOT_CY = 350 +# Quadratic segments used to approximate the circle. 8 is visually round at +# the sizes we render (sub-pixel deviation from a true circle) and still +# compresses to a tiny WOFF2. +DOT_SEGMENTS = 8 + + +def draw_dot(pen, cx, cy, radius, segments): + """Trace a circle as on-curve points joined by quadratic arcs. + + TrueType outlines are quadratic, so each arc uses a single off-curve + control point pushed out to ``radius / cos(half-step)`` — the standard + quadratic-Bezier circle approximation. + """ + step = 2.0 * math.pi / segments + control_radius = radius / math.cos(step / 2.0) + pen.moveTo((round(cx + radius), round(cy))) + for i in range(segments): + mid_angle = (i + 0.5) * step + end_angle = (i + 1) * step + control = ( + round(cx + control_radius * math.cos(mid_angle)), + round(cy + control_radius * math.sin(mid_angle)), + ) + end = ( + round(cx + radius * math.cos(end_angle)), + round(cy + radius * math.sin(end_angle)), + ) + pen.qCurveTo(control, end) + pen.closePath() + + +def main() -> None: + fb = FontBuilder(UPM, isTTF=True) + fb.setupGlyphOrder([".notdef", "space"]) + fb.setupCharacterMap({0x0020: "space"}) + + pen = TTGlyphPen(None) + draw_dot(pen, DOT_CX, DOT_CY, DOT_DIAMETER / 2.0, DOT_SEGMENTS) + space_glyph = pen.glyph() + + notdef_glyph = TTGlyphPen(None).glyph() + + fb.setupGlyf({".notdef": notdef_glyph, "space": space_glyph}) + + # The left side bearing must match the glyph's xMin. Browsers position the + # outline by its advertised lsb, so leaving it at 0 while the dot starts + # ~70 units in shoves the dot toward the left edge of the space instead of + # centering it. Derive it from the compiled bounds so it always tracks the + # geometry above. + glyf_table = fb.font["glyf"] + glyf_table["space"].recalcBounds(glyf_table) + space_lsb = glyf_table["space"].xMin + fb.setupHorizontalMetrics({".notdef": (ADVANCE, 0), "space": (ADVANCE, space_lsb)}) + fb.setupHorizontalHeader(ascent=800, descent=-200) + fb.setupOS2( + sTypoAscender=800, + sTypoDescender=-200, + usWinAscent=800, + usWinDescent=200, + sCapHeight=700, + sxHeight=500, + ) + fb.setupNameTable({"familyName": "LexicalSpaceDot", "styleName": "Regular"}) + fb.setupPost() + + ttf_path = "/tmp/LexicalSpaceDot.ttf" + woff2_path = "/tmp/LexicalSpaceDot.woff2" + fb.save(ttf_path) + + font = TTFont(ttf_path) + font.flavor = "woff2" + font.save(woff2_path) + + size = os.path.getsize(woff2_path) + with open(woff2_path, "rb") as f: + payload = base64.b64encode(f.read()).decode("ascii") + + sys.stdout.write(f"WOFF2 size: {size} bytes\n") + sys.stdout.write("Base64 payload (paste into @font-face src):\n") + sys.stdout.write(payload + "\n") + + +if __name__ == "__main__": + main() diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 1dee3512298..e1bcf336442 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -87,7 +87,7 @@ import {TerseExportExtension} from './plugins/TerseExportExtension'; import TestRecorderPlugin from './plugins/TestRecorderPlugin'; import {TwitterExtension} from './plugins/TwitterExtension'; import TypingPerfPlugin from './plugins/TypingPerfPlugin'; -import {VisibleLineBreakExtension} from './plugins/VisibleLineBreakExtension'; +import {VisibleNonPrintingExtension} from './plugins/VisibleNonPrintingExtension'; import {YouTubeExtension} from './plugins/YouTubeExtension'; import Settings from './Settings'; import PlaygroundEditorTheme from './themes/PlaygroundEditorTheme'; @@ -238,7 +238,7 @@ const AppExtension = defineExtension({ $defaultShouldInsertAfter(node) || $isCodeNode(node), }), configExtension(AutocompleteExtension, {disabled: true}), - configExtension(VisibleLineBreakExtension, {disabled: true}), + configExtension(VisibleNonPrintingExtension, {disabled: true}), // DOMImportExtension pipeline — `PlaygroundImportExtension` bundles // the shared `CoreImportExtension` baseline, every per-package // import extension (rich-text, list, link, table, code, hr), the diff --git a/packages/lexical-playground/src/Settings.tsx b/packages/lexical-playground/src/Settings.tsx index 6bfb7be2714..95f7fa13146 100644 --- a/packages/lexical-playground/src/Settings.tsx +++ b/packages/lexical-playground/src/Settings.tsx @@ -29,7 +29,7 @@ export default function Settings(): JSX.Element { isCharLimit, isCharLimitUtf8, isAutocomplete, - isVisibleLineBreak, + isVisibleNonPrinting, showTreeView, showNestedEditorTreeView, showTableOfContents, @@ -148,9 +148,11 @@ export default function Settings(): JSX.Element { text="Autocomplete" /> setOption('isVisibleLineBreak', !isVisibleLineBreak)} - checked={isVisibleLineBreak} - text="Visible Line Break" + onClick={() => + setOption('isVisibleNonPrinting', !isVisibleNonPrinting) + } + checked={isVisibleNonPrinting} + text="Visible Non-Printing" /> { diff --git a/packages/lexical-playground/src/appSettings.ts b/packages/lexical-playground/src/appSettings.ts index 12e4c4f0658..e3829e9d1ba 100644 --- a/packages/lexical-playground/src/appSettings.ts +++ b/packages/lexical-playground/src/appSettings.ts @@ -24,7 +24,7 @@ export const DEFAULT_SETTINGS = { isCollab: false, isMaxLength: false, isRichText: true, - isVisibleLineBreak: false, + isVisibleNonPrinting: false, listStrictIndent: false, measureTypingPerf: false, selectionAlwaysOnDisplay: false, diff --git a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts index 715bc074fb3..dafe43715bd 100644 --- a/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts +++ b/packages/lexical-playground/src/hooks/useSynchronizeSettings.ts @@ -32,7 +32,7 @@ import {AutocompleteExtension} from '../plugins/AutocompleteExtension'; import {CodeHighlightExtension} from '../plugins/CodeHighlightExtension'; import {MaxLengthExtension} from '../plugins/MaxLengthPlugin'; import {SpecialTextExtension} from '../plugins/SpecialTextExtension'; -import {VisibleLineBreakExtension} from '../plugins/VisibleLineBreakExtension'; +import {VisibleNonPrintingExtension} from '../plugins/VisibleNonPrintingExtension'; const DEFAULT_LINK_ATTRIBUTES: LinkAttributes = { rel: 'noopener noreferrer', @@ -86,8 +86,8 @@ export function synchronizeSettingsToSignals( batch(() => { output(editor, AutocompleteExtension).disabled.value = !settings.isAutocomplete; - output(editor, VisibleLineBreakExtension).disabled.value = - !settings.isVisibleLineBreak; + output(editor, VisibleNonPrintingExtension).disabled.value = + !settings.isVisibleNonPrinting; output(editor, MaxLengthExtension).disabled.value = !settings.isMaxLength; const codeHighlight = peerOutput(editor, CodeHighlightExtension); if (codeHighlight) { diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index dbfc58a016d..33f57eafb01 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -20,6 +20,9 @@ body { BlinkMacSystemFont, '.SFNSText-Regular', sans-serif; + --font-family: + system-ui, -apple-system, BlinkMacSystemFont, '.SFNSText-Regular', + sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; background: #eee; diff --git a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts b/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts deleted file mode 100644 index d622da9c4e0..00000000000 --- a/packages/lexical-playground/src/plugins/VisibleLineBreakExtension.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ -import {$isCodeNode} from '@lexical/code-core'; -import {effect, namedSignals} from '@lexical/extension'; -import { - $setRenderContextValue, - createRenderState, - domOverride, - DOMRenderExtension, -} from '@lexical/html'; -import { - configExtension, - defineExtension, - isHTMLElement, - LineBreakNode, - safeCast, -} from 'lexical'; - -/** - * Worked example for the generalized `getDOMSlot` abstraction — wraps each - * `LineBreakNode`'s `
` in a `` carrying a visible `↵` marker, and - * exposes the inner `
` through `$getDOMSlot` so selection / caret logic - * targets the canonical content element instead of the wrapper. - * - * Demonstrates the extension-driven path for a leaf node category: no - * `LineBreakNode` subclass required, behaviour attaches via - * `DOMRenderExtension` configuration. - * - * `disabled` toggles the wrap at runtime without recreating the editor. The - * override is installed conditionally via `disabledForEditor`, so when disabled - * it is removed from the render pipeline entirely rather than no-oping per - * node. Flipping the signal mirrors it into the editor render context with - * `$setRenderContextValue`, which recompiles the render config and recreates - * the existing `LineBreakNode` DOM through the new config. - */ -const VISIBLE_LINEBREAK_CLASS = 'visible-linebreak'; -const VISIBLE_LINEBREAK_ATTR = 'data-lexical-visible-linebreak'; - -export interface VisibleLineBreakConfig { - disabled: boolean; -} - -/** - * Editor render context state mirroring the extension's `disabled` signal. - */ -export const VisibleLineBreakDisabled = createRenderState( - 'visibleLineBreakDisabled', - () => false, -); - -function $skipForCodeChild(node: LineBreakNode): boolean { - // Code blocks convey line structure visually — skip the visible - // linebreak wrap anywhere inside a `CodeNode`. - for ( - let ancestor = node.getParent(); - ancestor !== null; - ancestor = ancestor.getParent() - ) { - if ($isCodeNode(ancestor)) { - return true; - } - } - return false; -} - -function hasOurWrap(dom: HTMLElement): boolean { - return dom.tagName === 'SPAN' && dom.hasAttribute(VISIBLE_LINEBREAK_ATTR); -} - -export const VisibleLineBreakExtension = defineExtension({ - build: (editor, config) => namedSignals(config), - config: safeCast({disabled: false}), - dependencies: [ - configExtension(DOMRenderExtension, { - overrides: [ - domOverride( - [LineBreakNode], - { - $createDOM: (node, $next) => { - const inner = $next(); - if ($skipForCodeChild(node)) { - return inner; - } - const wrapper = document.createElement('span'); - wrapper.className = VISIBLE_LINEBREAK_CLASS; - wrapper.setAttribute(VISIBLE_LINEBREAK_ATTR, 'true'); - wrapper.appendChild(inner); - return wrapper; - }, - $getDOMSlot: (_node, dom, $next) => { - const br = dom.querySelector(':scope > br'); - return isHTMLElement(br) ? $next().withElement(br) : $next(); - }, - $updateDOM: (node, _prev, dom, $next) => { - const wantsWrap = !$skipForCodeChild(node); - if (wantsWrap !== hasOurWrap(dom)) { - return true; - } - return $next(); - }, - }, - {disabledForEditor: ctx => ctx.get(VisibleLineBreakDisabled)}, - ), - ], - }), - ], - name: '@lexical/playground/visible-linebreak', - register: (editor, _config, state) => { - const stores = state.getOutput(); - return effect(() => { - $setRenderContextValue( - VisibleLineBreakDisabled, - stores.disabled.value, - editor, - ); - }); - }, -}); diff --git a/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts b/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts new file mode 100644 index 00000000000..d2a970a1266 --- /dev/null +++ b/packages/lexical-playground/src/plugins/VisibleNonPrintingExtension.ts @@ -0,0 +1,236 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {DOMOverrideOptions} from '@lexical/html'; + +import {$isCodeNode} from '@lexical/code-core'; +import {effect, namedSignals} from '@lexical/extension'; +import { + $setRenderContextValue, + createRenderState, + domOverride, + DOMRenderExtension, +} from '@lexical/html'; +import {ListItemNode} from '@lexical/list'; +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import {$canShowPlaceholder} from '@lexical/text'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import { + $isTabNode, + configExtension, + defineExtension, + ElementNode, + getStyleObjectFromCSS, + isHTMLElement, + LineBreakNode, + ParagraphNode, + safeCast, + TabNode, + TextNode, +} from 'lexical'; + +/** + * Surfaces a visible marker for each non-printing character in the editor. + * Currently covers: + * - `LineBreakNode` (`↵`) — wraps each `
` in a `` carrying the + * marker, and exposes the inner `
` through `$getDOMSlot` so selection / + * caret logic targets the canonical content element. + * - `ParagraphNode` / `HeadingNode` / `ListItemNode` / `QuoteNode` (`¶`) — + * shared `data-lexical-visible-non-printing-block` attribute on the block's + * DOM, visual rendered via CSS `::after`. The marker is `position: absolute` + * and `:has(> br:last-child)` snaps it to `bottom: 0` so a trailing + * placeholder `
` (added by `$reconcileElementTerminatingLineBreak` when + * Shift+Enter ends the block) shares the empty line instead of bumping the + * marker onto a new one. The direct-child selector keeps a wrapped + * `LineBreakNode`'s inner `
` from accidentally matching. + * - `TabNode` (`→`) — `data-` attribute marker rendered via CSS `::before`, + * centered with `position: absolute; transform: translate(-50%, -50%)` so the + * arrow sits in the middle of the tab whitespace rather than at the left + * edge. Indent applied via `INDENT_CONTENT_COMMAND` (Tab on a block-start + * caret) does not create a `TabNode` and is therefore not marked, matching + * Word's "Show formatting marks" behaviour where indent and literal tabs are + * distinct. + * - Space (` `, U+0020) (`·`) — an inline WOFF2 with `unicode-range: U+0020` + * remaps only the space glyph to a middle-dot shape, gated by the editor + * root's `data-lexical-visible-non-printing-active` attribute. A + * `TextNode` `$createDOM` / `$updateDOM` override prepends our font in + * front of any inline `style="font-family"` so the toolbar font-family + * selector can't strip the marker via specificity. Zero text-content + * mutation, so IME composition, selection, and caret behaviour stay + * intact. + * + * Demonstrates the extension-driven path for leaf and block node categories: + * no subclassing required, behaviour attaches via `DOMRenderExtension` + * configuration. + * + * `disabled` toggles the markers at runtime without recreating the editor. + * The overrides are installed conditionally via `disabledForEditor`, so when + * disabled they are removed from the render pipeline entirely rather than + * no-oping per node. Flipping the signal mirrors it into the editor render + * context with `$setRenderContextValue`, which recompiles the render config + * and recreates the existing DOM through the new config. The empty-root + * listener is registered inside the `effect` callback so it tears down when + * the extension is disabled instead of running forever. + */ +const VISIBLE_NON_PRINTING_LINEBREAK_ATTR = + 'data-lexical-visible-non-printing-linebreak'; +const VISIBLE_NON_PRINTING_BLOCK_ATTR = + 'data-lexical-visible-non-printing-block'; +const VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR = + 'data-lexical-visible-non-printing-empty-root'; +const VISIBLE_NON_PRINTING_ACTIVE_ATTR = + 'data-lexical-visible-non-printing-active'; +const VISIBLE_NON_PRINTING_TAB_ATTR = 'data-lexical-visible-non-printing-tab'; + +export interface VisibleNonPrintingConfig { + disabled: boolean; +} + +/** + * Editor render context state mirroring the extension's `disabled` signal. + */ +export const VisibleNonPrintingDisabled = createRenderState( + 'visibleNonPrintingDisabled', + () => false, +); + +function $skipForCodeChild(node: LineBreakNode): boolean { + // Code blocks convey line structure visually — skip the visible + // linebreak wrap anywhere inside a `CodeNode`. + return $findMatchingParent(node, $isCodeNode) !== null; +} + +function hasOurWrap(dom: HTMLElement): boolean { + return ( + dom.tagName === 'SPAN' && + dom.hasAttribute(VISIBLE_NON_PRINTING_LINEBREAK_ATTR) + ); +} + +const LEXICAL_SPACE_DOT_FONT = "'LexicalSpaceDot'"; + +const disabledForEditor = { + disabledForEditor: ctx => ctx.get(VisibleNonPrintingDisabled), +} satisfies DOMOverrideOptions; + +export const VisibleNonPrintingExtension = defineExtension({ + build: (editor, config) => namedSignals(config), + config: safeCast({disabled: false}), + dependencies: [ + configExtension(DOMRenderExtension, { + overrides: [ + domOverride( + [LineBreakNode], + { + $createDOM: (node, $next) => { + const inner = $next(); + if ($skipForCodeChild(node)) { + return inner; + } + const wrapper = document.createElement('span'); + wrapper.setAttribute(VISIBLE_NON_PRINTING_LINEBREAK_ATTR, 'true'); + wrapper.appendChild(inner); + return wrapper; + }, + $getDOMSlot: (_node, dom, $next) => { + const br = dom.querySelector(':scope > br'); + return isHTMLElement(br) ? $next().withElement(br) : $next(); + }, + $updateDOM: (node, _prev, dom, $next) => { + const wantsWrap = !$skipForCodeChild(node); + if (wantsWrap !== hasOurWrap(dom)) { + return true; + } + return $next(); + }, + }, + disabledForEditor, + ), + domOverride( + [ParagraphNode, HeadingNode, ListItemNode, QuoteNode], + { + $decorateDOM: (node, _prevNode, dom) => { + dom.setAttribute(VISIBLE_NON_PRINTING_BLOCK_ATTR, 'true'); + const nextTextStyle = getStyleObjectFromCSS(node.__textStyle); + for (const prop of ['font-size', 'font-weight', 'font-family']) { + dom.style.setProperty( + `--text-${prop}`, + nextTextStyle[prop] || null, + ); + } + }, + }, + disabledForEditor, + ), + domOverride( + [TextNode, TabNode], + { + $decorateDOM: (node, _prev, dom) => { + // TabNode descends from TextNode so we are going to hit this case either way + if ($isTabNode(node)) { + dom.setAttribute(VISIBLE_NON_PRINTING_TAB_ATTR, 'true'); + return; + } + // Prepend our space-dot font in front of any inline `font-family` set on a + // TextNode's span (e.g. by the playground's font-family toolbar). The root + // rule's `font-family` stack already covers spans without an inline style; + // the inline one wins specificity, so we have to apply the override at the + // node level. `unicode-range: U+0020` still scopes the substitution to the + // space glyph alone, so the user's chosen face renders every other glyph. + const inline = + dom.style.fontFamily || + getStyleObjectFromCSS(node.__style)['font-family'] || + 'var(--font-family)'; + if (inline.startsWith(LEXICAL_SPACE_DOT_FONT)) { + return; + } + dom.style.fontFamily = `${LEXICAL_SPACE_DOT_FONT}, ${inline}`; + }, + }, + disabledForEditor, + ), + ], + }), + ], + name: '@lexical/playground/visible-non-printing', + register: (editor, _config, state) => { + const stores = state.getOutput(); + return effect(() => { + const isDisabled = stores.disabled.value; + $setRenderContextValue(VisibleNonPrintingDisabled, isDisabled, editor); + if (isDisabled) { + return; + } + return editor.registerRootListener(nextRoot => { + if (nextRoot === null) { + return; + } + nextRoot.setAttribute(VISIBLE_NON_PRINTING_ACTIVE_ATTR, 'true'); + const syncEmptyRootAttr = () => { + const showPlaceholder = editor + .getEditorState() + .read(() => $canShowPlaceholder(editor.isComposing())); + nextRoot.toggleAttribute( + VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR, + showPlaceholder, + ); + }; + syncEmptyRootAttr(); + // mergeRegister tears down in LIFO order: the attribute removal runs + // before the update listener unregister, so `syncEmptyRootAttr` can't + // refire on a half-cleaned root between the two cleanups. + return mergeRegister( + editor.registerUpdateListener(syncEmptyRootAttr), + () => { + nextRoot.removeAttribute(VISIBLE_NON_PRINTING_ACTIVE_ATTR); + nextRoot.removeAttribute(VISIBLE_NON_PRINTING_EMPTY_ROOT_ATTR); + }, + ); + }); + }); + }, +}); diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index ab779f1d39b..e25ad977462 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -118,6 +118,7 @@ .PlaygroundEditorTheme__textCode { background-color: rgb(240, 242, 245); padding: 1px 0.25rem; + --font-family: Menlo, Consolas, Monaco, monospace; font-family: Menlo, Consolas, Monaco, monospace; font-size: 94%; } @@ -164,6 +165,7 @@ .PlaygroundEditorTheme__code { background-color: rgb(240, 242, 245); font-family: Menlo, Consolas, Monaco, monospace; + --font-family: Menlo, Consolas, Monaco, monospace; display: block; line-height: 1.53; font-size: 13px; @@ -740,10 +742,73 @@ min-height: var(--page-height); } } -[data-lexical-visible-linebreak]::before { +[data-lexical-visible-non-printing-linebreak]::before { content: '↵'; color: #999; pointer-events: none; user-select: none; -webkit-user-select: none; + position: absolute; +} +[data-lexical-visible-non-printing-block] { + position: relative; +} +[data-lexical-visible-non-printing-block]::after { + content: '¶'; + color: #999; + pointer-events: none; + user-select: none; + -webkit-user-select: none; + font-size: var(--text-font-size, inherit); + font-weight: var(--text-font-weight, inherit); + font-family: var(--text-font-family, inherit); + text-decoration: none; + position: absolute; +} +[data-lexical-visible-non-printing-block]:has(br:only-child) { + font-size: var(--text-font-size, inherit); + font-weight: var(--text-font-weight, inherit); + font-family: var(--text-font-family, inherit); +} +[data-lexical-visible-non-printing-block]:has(br:last-child)::after { + bottom: 0; +} +[data-lexical-visible-non-printing-empty-root] + [data-lexical-visible-non-printing-block]::after { + content: '\200B'; +} +[data-lexical-visible-non-printing-tab] { + position: relative; +} +[data-lexical-visible-non-printing-tab]::before { + content: ''; + position: absolute; + left: 8%; + right: 8%; + top: 50%; + height: 8px; + transform: translateY(-50%); + /* Two-layer composition: fixed-size SVG arrowhead pinned to the right end, + plus a 1px stem that stretches across the rest of the tab whitespace. + Decouples the arrowhead shape from tab width so heading fonts (large tab + widths) and body fonts (small tab widths) both render a sane arrow. */ + background: + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 6 8'%3E%3Cpath d='M0 0 L6 4 L0 8' fill='none' stroke='%23999' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") + no-repeat right center / 6px 8px, + linear-gradient(#999, #999) no-repeat left center / calc(100% - 6px) 1px; + pointer-events: none; + user-select: none; + -webkit-user-select: none; +} +/* Inline WOFF2 (~308 bytes) whose only glyph is a 120×120 unit square dot + centered at (130, 350) in a 1000 UPM space slot — maps U+0020 to a middle + dot for the VisibleNonPrintingExtension. Regenerate via + packages/lexical-playground/scripts/build-space-dot-font.py (requires + fonttools + brotli). */ +@font-face { + font-family: 'LexicalSpaceDot'; + src: url('data:font/woff2;base64,d09GMgABAAAAAAFMAAoAAAAAAqQAAAEFAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAANAo4TwE2AiQDBgsGAAQgBXgHJhsAAiAvCmwbtjU1FiYhthYrpIeZP0WUDdns/1FKf0mxBFuaQjicQUicrhqlCPGg0W2W9y1dXXVcQyQEwQiD/qtPR6qarErVtE5oiiLwZ2DZQ/6H47jBcUQBQcKB0aD7Io/jhLuN+fQIbnfU7qKeyosJRQxw2QbD6OM/+vAFkF96xwANHeu6YF0ZKCUY/zzJAqCBQrCMVUABsvzk9M3G8Xrmm8uz/2E7y9nZyfIO60AQXnvE07/p3n/g+9o3cg8hn1joGggzLUp50HwIACApPKholgU0AACUCwGxhIBmypIuNSMObVbYLDp//JnAb9cc+UrqG6VTjxHgpg2EYiENarkcHCTaCQA=') + format('woff2'); + unicode-range: U+0020; + font-display: block; } diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx index 44c6fbe5992..db8cf4a97bc 100644 --- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx +++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx @@ -58,6 +58,16 @@ import {InitialEditorStateType} from '../LexicalComposer'; export type CursorsContainerRef = React.RefObject; +/** + * Well-known key under which the active Yjs {@link UndoManager} is published on + * the editor instance (mirroring how `@lexical/extension` attaches its builder + * via a `Symbol.for` key). Collab disables `@lexical/history`, so this is the + * handle tooling and e2e tests use to force a deterministic undo boundary via + * `editor[COLLAB_UNDO_MANAGER]?.stopCapturing()` instead of waiting out the + * UndoManager capture timeout. + */ +const COLLAB_UNDO_MANAGER = Symbol.for('@lexical/yjs/UndoManager'); + type OnYjsTreeChanges = ( // The below `any` type is taken directly from the vendor types for YJS. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -560,6 +570,20 @@ function useYjsUndoManager(editor: LexicalEditor, undoManager: UndoManager) { ), ); }); + // Publish the UndoManager on the editor (see COLLAB_UNDO_MANAGER) so tooling + // and e2e tests can reach it; remove it again when it changes or unmounts. + useEffect(() => { + const withManager = editor as LexicalEditor & + Record; + // eslint-disable-next-line react-hooks/immutability + withManager[COLLAB_UNDO_MANAGER] = undoManager; + return () => { + if (withManager[COLLAB_UNDO_MANAGER] === undoManager) { + delete withManager[COLLAB_UNDO_MANAGER]; + } + }; + }, [editor, undoManager]); + const clearHistory = useCallback(() => { undoManager.clear(); }, [undoManager]); diff --git a/packages/lexical-website/docs/serialization/dom-render.md b/packages/lexical-website/docs/serialization/dom-render.md index 3f2679f5809..a138a0457a7 100644 --- a/packages/lexical-website/docs/serialization/dom-render.md +++ b/packages/lexical-website/docs/serialization/dom-render.md @@ -450,7 +450,7 @@ configExtension(DOMRenderExtension, { { $createDOM(node, $next) { const wrapper = document.createElement('span'); - wrapper.className = 'visible-linebreak'; + wrapper.className = 'visible-non-printing-linebreak'; wrapper.appendChild($next()); return wrapper; },