From 574685ea48ae42ddb13ed4aaf15e03e836214e4c Mon Sep 17 00:00:00 2001 From: mayrang Date: Tue, 7 Jul 2026 13:13:31 +0900 Subject: [PATCH 1/4] [lexical-playground] Bug Fix: Use viewBox dimensions for unsized Excalidraw output (#8798) --- .../nodes/ExcalidrawNode/ExcalidrawImage.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawImage.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawImage.tsx index 143f01ee916..8c8dfdca653 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawImage.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawImage.tsx @@ -15,7 +15,7 @@ import type {JSX} from 'react'; import {exportToSvg} from '@excalidraw/excalidraw'; import * as React from 'react'; -import {useEffect, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; type ImageType = 'svg' | 'canvas'; @@ -102,16 +102,28 @@ export default function ExcalidrawImage({ files, }); removeStyleFromSvg_HACK(svg); - - svg.setAttribute('width', '100%'); - svg.setAttribute('height', '100%'); svg.setAttribute('display', 'block'); setSvg(svg); }; - setContent(); + setContent().catch(console.error); }, [elements, files, appState]); + const svgHtml = useMemo(() => { + if (Svg == null) { + return ''; + } + const clone = Svg.cloneNode(true) as SVGElement; + if (width === 'inherit' && height === 'inherit') { + clone.style.maxWidth = '100%'; + clone.style.height = 'auto'; + } else { + clone.setAttribute('width', '100%'); + clone.setAttribute('height', '100%'); + } + return clone.outerHTML; + }, [Svg, width, height]); + const containerStyle: React.CSSProperties = {}; if (width !== 'inherit') { containerStyle.width = `${width}px`; @@ -131,7 +143,7 @@ export default function ExcalidrawImage({ }} className={rootClassName ?? ''} style={containerStyle} - dangerouslySetInnerHTML={{__html: Svg?.outerHTML ?? ''}} + dangerouslySetInnerHTML={{__html: svgHtml}} /> ); } From 38051a7498461da265a9b670ef1f3585319d4543 Mon Sep 17 00:00:00 2001 From: mayrang Date: Tue, 7 Jul 2026 13:16:50 +0900 Subject: [PATCH 2/4] [lexical][lexical-playground] Chore: Refactor IME composition test infrastructure and add browser-level coverage (#8793) Co-authored-by: Bob Ippolito --- .../__tests__/e2e/Composition.spec.mjs | 676 +----------------- .../__tests__/utils/index.mjs | 44 ++ .../browser/LexicalComposition.test.ts | 605 ++++++++++++++++ .../src/__tests__/browser/utils/compose.ts | 331 +++++++++ .../LexicalAndroidChromeComposition.test.ts | 63 +- .../LexicalFirefoxCompositionEndTag.test.ts | 47 +- .../unit/LexicalIosKoreanIME.test.ts | 123 +--- 7 files changed, 1085 insertions(+), 804 deletions(-) create mode 100644 packages/lexical/src/__tests__/browser/LexicalComposition.test.ts create mode 100644 packages/lexical/src/__tests__/browser/utils/compose.ts diff --git a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs index 6158f09a1cf..70eb54b3f56 100644 --- a/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Composition.spec.mjs @@ -21,11 +21,15 @@ import { evaluate, expect, focusEditor, + HIRAGANA_MOJIA, + HIRAGANA_SUSHI, html, + imeCompose, initialize, keyDownCtrlOrMeta, keyUpCtrlOrMeta, test, + typeSushiMojia, waitForSelector, waitForTypeaheadMenuOption, } from '../utils/index.mjs'; @@ -183,66 +187,7 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); const client = await page.context().newCDPSession(page); - - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - await client.send('Input.insertText', { - text: 'すし', - }); - await client.send('Input.insertText', { - text: ' ', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await typeSushiMojia(client, page); await assertHTML( page, @@ -281,78 +226,7 @@ test.describe('Composition', () => { await page.keyboard.press('ArrowLeft'); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); - // await page.keyboard.type(' '); - await client.send('Input.insertText', { - text: ' ', - }); - // await page.keyboard.imeSetComposition('m', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - // await page.keyboard.imeSetComposition('も', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - // await page.keyboard.imeSetComposition('もj', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - // await page.keyboard.imeSetComposition('もじ', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - // await page.keyboard.imeSetComposition('もじあ', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - // await page.keyboard.insertText('もじあ'); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await typeSushiMojia(client, page); await assertHTML( page, @@ -391,40 +265,7 @@ test.describe('Composition', () => { await keyUpCtrlOrMeta(page); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await assertHTML( page, @@ -460,75 +301,9 @@ test.describe('Composition', () => { await page.keyboard.press('ArrowLeft'); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await page.keyboard.type(' '); - // await page.keyboard.imeSetComposition('m', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - // await page.keyboard.imeSetComposition('も', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - // await page.keyboard.imeSetComposition('もj', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - // await page.keyboard.imeSetComposition('もじ', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - // await page.keyboard.imeSetComposition('もじあ', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - // await page.keyboard.insertText('もじあ'); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await imeCompose(client, HIRAGANA_MOJIA.steps, HIRAGANA_MOJIA.commitText); await assertHTML( page, @@ -572,50 +347,11 @@ test.describe('Composition', () => { focusPath: [0, 0, 0, 0], }); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('', 0, 0); - await client.send('Input.imeSetComposition', { - selectionStart: 0, - selectionEnd: 0, - text: '', - }); - // Escape would fire here - await page.keyboard.insertText(''); + await imeCompose( + client, + ['s', 'す', 'すs', 'すsh', 'すし', 'す', ''], + '', + ); await assertHTML( page, @@ -655,75 +391,9 @@ test.describe('Composition', () => { await waitForSelector(page, '.mention'); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await page.keyboard.type(' '); - // await page.keyboard.imeSetComposition('m', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - // await page.keyboard.imeSetComposition('も', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - // await page.keyboard.imeSetComposition('もj', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - // await page.keyboard.imeSetComposition('もじ', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - // await page.keyboard.imeSetComposition('もじあ', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - // await page.keyboard.insertText('もじあ'); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await imeCompose(client, HIRAGANA_MOJIA.steps, HIRAGANA_MOJIA.commitText); await assertHTML( page, @@ -767,75 +437,9 @@ test.describe('Composition', () => { await moveLeft(page, 9); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await page.keyboard.type(' '); - // await page.keyboard.imeSetComposition('m', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - // await page.keyboard.imeSetComposition('も', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - // await page.keyboard.imeSetComposition('もj', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - // await page.keyboard.imeSetComposition('もじ', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - // await page.keyboard.imeSetComposition('もじあ', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - // await page.keyboard.insertText('もじあ'); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await imeCompose(client, HIRAGANA_MOJIA.steps, HIRAGANA_MOJIA.commitText); await assertHTML( page, @@ -870,40 +474,7 @@ test.describe('Composition', () => { await page.keyboard.press('Enter'); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await assertHTML( page, @@ -936,76 +507,10 @@ test.describe('Composition', () => { await page.keyboard.type('#'); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await page.keyboard.type(' '); - // await page.keyboard.imeSetComposition('m', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'm', - }); - // await page.keyboard.imeSetComposition('も', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'も', - }); - // await page.keyboard.imeSetComposition('もj', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もj', - }); - // await page.keyboard.imeSetComposition('もじ', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'もじ', - }); - // await page.keyboard.imeSetComposition('もじあ', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'もじあ', - }); - // await page.keyboard.insertText('もじあ'); - await client.send('Input.insertText', { - text: 'もじあ', - }); + await imeCompose(client, HIRAGANA_MOJIA.steps, HIRAGANA_MOJIA.commitText); await assertHTML( page, @@ -1029,40 +534,7 @@ test.describe('Composition', () => { await moveToLineBeginning(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.insertText('すし'); - await client.send('Input.insertText', { - text: 'すし', - }); + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); await assertHTML( page, @@ -1091,50 +563,11 @@ test.describe('Composition', () => { await enableCompositionKeyEvents(page); const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('', 0, 0); - await client.send('Input.imeSetComposition', { - selectionStart: 0, - selectionEnd: 0, - text: '', - }); - // Escape would fire here - await page.keyboard.insertText(''); + await imeCompose( + client, + ['s', 'す', 'すs', 'すsh', 'すし', 'す', ''], + '', + ); await assertHTML( page, @@ -1154,50 +587,11 @@ test.describe('Composition', () => { await page.keyboard.type(' '); await page.keyboard.press('ArrowLeft'); - // await page.keyboard.imeSetComposition('s', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 's', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('すs', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すs', - }); - // await page.keyboard.imeSetComposition('すsh', 3, 3); - await client.send('Input.imeSetComposition', { - selectionStart: 3, - selectionEnd: 3, - text: 'すsh', - }); - // await page.keyboard.imeSetComposition('すし', 2, 2); - await client.send('Input.imeSetComposition', { - selectionStart: 2, - selectionEnd: 2, - text: 'すし', - }); - // await page.keyboard.imeSetComposition('す', 1, 1); - await client.send('Input.imeSetComposition', { - selectionStart: 1, - selectionEnd: 1, - text: 'す', - }); - // await page.keyboard.imeSetComposition('', 0, 0); - await client.send('Input.imeSetComposition', { - selectionStart: 0, - selectionEnd: 0, - text: '', - }); - // Escape would fire here - await page.keyboard.insertText(''); + await imeCompose( + client, + ['s', 'す', 'すs', 'すsh', 'すし', 'す', ''], + '', + ); await assertHTML( page, @@ -1228,31 +622,26 @@ test.describe('Composition', () => { const client = await page.context().newCDPSession(page); - // await page.keyboard.imeSetComposition('s', 0, 1); await client.send('Input.imeSetComposition', { selectionStart: 0, selectionEnd: 1, text: 's', }); - // await page.keyboard.imeSetComposition('す', 0, 1); await client.send('Input.imeSetComposition', { selectionStart: 0, selectionEnd: 1, text: 'す', }); - // await page.keyboard.imeSetComposition('すs', 0, 2); await client.send('Input.imeSetComposition', { selectionStart: 0, selectionEnd: 2, text: 'すs', }); - // await page.keyboard.imeSetComposition('すsh', 0, 3); await client.send('Input.imeSetComposition', { selectionStart: 0, selectionEnd: 3, text: 'すsh', }); - // await page.keyboard.imeSetComposition('すsh', 0, 4); await client.send('Input.imeSetComposition', { selectionStart: 0, // The fourth character in the DOM is a zero-width space @@ -1267,7 +656,6 @@ test.describe('Composition', () => { expect(isFloatingToolbarDisplayedWhenUseIME).toEqual(false); - // await page.keyboard.insertText('すsh'); await client.send('Input.insertText', { text: 'すsh', }); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index 738bcd36682..7e5ed8c5b41 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -1435,6 +1435,50 @@ export async function enableCompositionKeyEvents(page) { }); } +/** + * CDP-based IME composition helper for e2e tests. + * + * Plays an IME composition sequence through Chrome DevTools Protocol, + * replacing the verbose per-step client.send() calls that are + * copy-pasted across Composition.spec tests. + * + * @param {import('@playwright/test').CDPSession} client + * @param {string[]} steps - Intermediate composing text at each keystroke. + * @param {string} [commitText] - Final committed text. Defaults to last step. + */ +export async function imeCompose(client, steps, commitText) { + const finalText = commitText ?? steps[steps.length - 1]; + for (const text of steps) { + await client.send('Input.imeSetComposition', { + selectionEnd: text.length, + selectionStart: text.length, + text, + }); + } + await client.send('Input.insertText', {text: finalText}); +} + +// Pre-built Hiragana sequences used across multiple e2e tests. +export const HIRAGANA_SUSHI = { + commitText: 'すし', + steps: ['s', 'す', 'すs', 'すsh', 'すし'], +}; + +export const HIRAGANA_MOJIA = { + commitText: 'もじあ', + steps: ['m', 'も', 'もj', 'もじ', 'もじあ'], +}; + +/** + * Types "すし もじあ" using CDP IME — the full sequence used in most + * Composition.spec tests. + */ +export async function typeSushiMojia(client, page) { + await imeCompose(client, HIRAGANA_SUSHI.steps, HIRAGANA_SUSHI.commitText); + await client.send('Input.insertText', {text: ' '}); + await imeCompose(client, HIRAGANA_MOJIA.steps, HIRAGANA_MOJIA.commitText); +} + export async function pressToggleBold(page) { await keyDownCtrlOrMeta(page); await page.keyboard.press('b'); diff --git a/packages/lexical/src/__tests__/browser/LexicalComposition.test.ts b/packages/lexical/src/__tests__/browser/LexicalComposition.test.ts new file mode 100644 index 00000000000..b8897ebf717 --- /dev/null +++ b/packages/lexical/src/__tests__/browser/LexicalComposition.test.ts @@ -0,0 +1,605 @@ +/** + * 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 {buildEditorFromExtensions} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {RichTextExtension} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + COMPOSITION_END_COMMAND, + COMPOSITION_END_TAG, + COMPOSITION_START_TAG, + LexicalEditor, + UNDO_COMMAND, +} from 'lexical'; +import {assert, describe, expect, onTestFinished, test} from 'vitest'; + +import {compose, korean} from './utils/compose'; + +const IS_FIREFOX = + typeof navigator !== 'undefined' && /Firefox/i.test(navigator.userAgent); + +function createEditor(opts?: { + initialState?: () => void; + withHistory?: boolean; +}): LexicalEditor { + const {initialState, withHistory} = opts ?? {}; + const dependencies = withHistory + ? [RichTextExtension, HistoryExtension] + : [RichTextExtension]; + const editor = buildEditorFromExtensions({ + $initialEditorState: + initialState ?? + (() => { + $getRoot().append($createParagraphNode()); + }), + dependencies, + name: 'test', + }); + const root = document.createElement('div'); + root.contentEditable = 'true'; + document.body.appendChild(root); + editor.setRootElement(root); + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(root); + }); + return editor; +} + +async function waitForRender(): Promise { + await new Promise(r => setTimeout(r, 0)); +} + +async function focusAtEnd(rootElement: HTMLElement): Promise { + await waitForRender(); + rootElement.focus(); + const textSpan = rootElement.querySelector('[data-lexical-text]'); + if (textSpan?.firstChild instanceof Text) { + const sel = document.getSelection()!; + sel.collapse(textSpan.firstChild, textSpan.firstChild.nodeValue!.length); + } +} + +async function focusAtStart(rootElement: HTMLElement): Promise { + await waitForRender(); + rootElement.focus(); + const p = rootElement.querySelector('p'); + if (p) { + const sel = document.getSelection()!; + sel.collapse(p, 0); + } +} + +describe('compose() helper — browser composition tests', () => { + test('Korean jamo composition produces correct text', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('한'); + const sel = $getSelection(); + assert($isRangeSelection(sel)); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.offset).toBe(1); + }); + }); + + test('Korean two-syllable composition', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + await compose({editor, rootElement}, korean(['ㄱ', '그', '글'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('한글'); + }); + + test('composition into existing text', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('hello ')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose( + {editor, rootElement}, + korean(['ㅅ', '세', '셰', '셰ㄱ', '세계']), + ); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('hello 세계'); + }); + + test('cancelled composition reverts text', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('abc')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose( + {editor, rootElement}, + { + cancel: true, + steps: [{text: 'ㅎ'}, {text: '하'}], + }, + ); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('abc'); + }); + + test('composition on bold-formatted text', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + const bold = $createTextNode('bold').toggleFormat('bold'); + p.append(bold); + $getRoot().append(p); + bold.selectEnd(); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + editor.read(() => { + const text = $getRoot().getTextContent(); + expect(text).toBe('bold한'); + }); + }); + + test('composition replaces selected text', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + const node = $createTextNode('hello'); + p.append(node); + $getRoot().append(p); + node.select(0, 5); + }, + }); + const rootElement = editor.getRootElement()!; + await waitForRender(); + // Place a real DOM selection over the text so compose() picks it up. + const textSpan = rootElement.querySelector('[data-lexical-text]'); + if (textSpan?.firstChild instanceof Text) { + document + .getSelection()! + .setBaseAndExtent(textSpan.firstChild, 0, textSpan.firstChild, 5); + } + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('한'); + }); + + test('three consecutive compositions', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + await compose({editor, rootElement}, korean(['ㄱ', '그', '글'])); + await compose({editor, rootElement}, korean(['ㅇ', '이', '임'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('한글임'); + }); + + test('composition at middle of text', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + const node = $createTextNode('helloworld'); + p.append(node); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await waitForRender(); + // Place cursor at offset 5 (between "hello" and "world"). + const textSpan = rootElement.querySelector('[data-lexical-text]'); + if (textSpan?.firstChild instanceof Text) { + document.getSelection()!.collapse(textSpan.firstChild, 5); + } + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + editor.read(() => { + expect($getRoot().getTextContent()).toBe('hello한world'); + const sel = $getSelection(); + assert($isRangeSelection(sel)); + expect(sel.isCollapsed()).toBe(true); + expect(sel.anchor.offset).toBe(6); + }); + }); + + test('composition then undo reverts to previous state', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('abc')); + $getRoot().append(p); + }, + withHistory: true, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + expect(editor.read(() => $getRoot().getTextContent())).toBe('abc한'); + + editor.dispatchCommand(UNDO_COMMAND, undefined); + await waitForRender(); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('abc'); + }); + + test('Japanese romaji-to-hiragana composition', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose( + {editor, rootElement}, + { + commitText: 'すし', + steps: [ + {text: 's'}, + {text: 'す'}, + {text: 'すs'}, + {text: 'すsh'}, + {text: 'すし'}, + ], + }, + ); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('すし'); + }); + + test('composition after arrow navigation', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('ab')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + // Move cursor left once: "ab|" → "a|b" + rootElement.dispatchEvent( + new KeyboardEvent('keydown', {bubbles: true, key: 'ArrowLeft'}), + ); + await waitForRender(); + // Manually adjust DOM selection to match (untrusted keydown doesn't move cursor). + const textSpan = rootElement.querySelector('[data-lexical-text]'); + if (textSpan?.firstChild instanceof Text) { + document.getSelection()!.collapse(textSpan.firstChild, 1); + } + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('a한b'); + }); +}); + +describe('Composition edge cases', () => { + test('composition in empty paragraph (element anchor ZWSP path)', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + // Empty paragraph has no text spans before composition. + expect(rootElement.querySelector('[data-lexical-text]')).toBeNull(); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('한'); + // ZWSP path must have created a text node; verify it exists post-compose. + expect(rootElement.querySelector('[data-lexical-text]')).not.toBeNull(); + }); + + test('composition on token node redirects to sibling', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + const token = $createTextNode('code').setMode('token'); + p.append(token); + $getRoot().append(p); + token.selectEnd(); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('code한'); + }); + + test('backspace-all composition ends with empty data', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('abc')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + // Simulate typing then backspacing all composed text. + await compose( + {editor, rootElement}, + { + commitText: '', + steps: [{text: 'ㅎ'}, {text: '하'}, {text: 'ㅎ'}, {text: ''}], + }, + ); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('abc'); + }); + + test('composition with newline commit creates new paragraph', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose( + {editor, rootElement}, + { + commitText: '確定\n', + steps: [{text: '確'}, {text: '確定'}], + }, + ); + + editor.read(() => { + const children = $getRoot().getChildren(); + expect(children.length).toBe(2); + }); + }); + + test('latin text before and after composition', async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('abc')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + editor.update(() => { + const sel = $getSelection(); + if ($isRangeSelection(sel)) { + sel.insertText('def'); + } + }); + await waitForRender(); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('abc한def'); + }); + + test('composition with explicit selection range', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + await compose( + {editor, rootElement}, + { + commitText: 'かん', + steps: [ + {selectionEnd: 1, selectionStart: 0, text: 'か'}, + {selectionEnd: 2, selectionStart: 0, text: 'かん'}, + ], + }, + ); + + const text = editor.read(() => $getRoot().getTextContent()); + expect(text).toBe('かん'); + }); +}); + +describe('Composition state tracking', () => { + test('editor.isComposing() is true during composition, false after', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + expect(editor.isComposing()).toBe(false); + + // Start composition manually to check mid-composition state. + rootElement.dispatchEvent( + new CompositionEvent('compositionstart', {bubbles: true, data: ''}), + ); + await waitForRender(); + expect(editor.isComposing()).toBe(true); + + // Dispatch COMPOSITION_END_COMMAND directly (bypasses platform deferral). + editor.dispatchCommand( + COMPOSITION_END_COMMAND, + new CompositionEvent('compositionend', {bubbles: true, data: ''}), + ); + await waitForRender(); + + expect(editor.isComposing()).toBe(false); + }); + + test('composing text is visible in model before commit', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + // Manually dispatch composition start + one step (no commit). + rootElement.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Process', + keyCode: 229, + }), + ); + rootElement.dispatchEvent( + new CompositionEvent('compositionstart', { + bubbles: true, + cancelable: true, + data: '', + }), + ); + await waitForRender(); + expect(editor.isComposing()).toBe(true); + + const composingStart = document.getSelection()?.focusOffset ?? 0; + + rootElement.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + cancelable: true, + data: 'ㅎ', + }), + ); + + // DOM mutation (untrusted events don't trigger real mutations). + const sel = document.getSelection()!; + let textNode: Text; + if (sel.focusNode instanceof Text && rootElement.contains(sel.focusNode)) { + textNode = sel.focusNode; + } else if ( + sel.focusNode instanceof HTMLElement && + rootElement.contains(sel.focusNode) + ) { + const br = sel.focusNode.querySelector('br'); + if (br) { + br.remove(); + } + textNode = document.createTextNode(''); + sel.focusNode.appendChild(textNode); + } else { + throw new Error('no insertion point'); + } + const v = textNode.nodeValue || ''; + textNode.nodeValue = + v.slice(0, composingStart) + 'ㅎ' + v.slice(composingStart); + sel.collapse(textNode, composingStart + 1); + + const bi = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + data: 'ㅎ', + inputType: 'insertCompositionText', + }); + Object.defineProperty(bi, 'getTargetRanges', {value: () => []}); + rootElement.dispatchEvent(bi); + + const inp = new InputEvent('input', { + bubbles: true, + cancelable: false, + data: 'ㅎ', + inputType: 'insertCompositionText', + }); + Object.defineProperty(inp, 'isComposing', {value: true}); + rootElement.dispatchEvent(inp); + await waitForRender(); + + // Model must reflect composing text while composition is still active. + // ZWSP may be present (inserted at compositionstart for element anchors). + expect(editor.read(() => $getRoot().getTextContent())).toContain('ㅎ'); + expect(editor.isComposing()).toBe(true); + + // Dispatch COMPOSITION_END_COMMAND directly (bypasses platform deferral). + editor.dispatchCommand( + COMPOSITION_END_COMMAND, + new CompositionEvent('compositionend', {bubbles: true, data: 'ㅎ'}), + ); + await waitForRender(); + expect(editor.isComposing()).toBe(false); + }); + + test('COMPOSITION_START_TAG and COMPOSITION_END_TAG appear in updates', async () => { + const editor = createEditor(); + const rootElement = editor.getRootElement()!; + await focusAtStart(rootElement); + + const observedTags: string[][] = []; + editor.registerUpdateListener(({tags}) => { + if (tags.size > 0) { + observedTags.push(Array.from(tags)); + } + }); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + expect( + observedTags.some(tags => tags.includes(COMPOSITION_START_TAG)), + ).toBe(true); + expect(observedTags.some(tags => tags.includes(COMPOSITION_END_TAG))).toBe( + true, + ); + }); +}); + +describe('Firefox deferred compositionend', () => { + test.skipIf(!IS_FIREFOX)( + 'COMPOSITION_END_TAG is emitted via deferred onInput path', + async () => { + const editor = createEditor({ + initialState: () => { + const p = $createParagraphNode(); + p.append($createTextNode('-')); + $getRoot().append(p); + }, + }); + const rootElement = editor.getRootElement()!; + await focusAtEnd(rootElement); + + const observedTags: string[][] = []; + editor.registerUpdateListener(({tags}) => { + observedTags.push(Array.from(tags)); + }); + + await compose({editor, rootElement}, korean(['ㅎ', '하', '한'])); + + expect( + observedTags.some(tags => tags.includes(COMPOSITION_END_TAG)), + ).toBe(true); + }, + ); +}); diff --git a/packages/lexical/src/__tests__/browser/utils/compose.ts b/packages/lexical/src/__tests__/browser/utils/compose.ts new file mode 100644 index 00000000000..485fd67395d --- /dev/null +++ b/packages/lexical/src/__tests__/browser/utils/compose.ts @@ -0,0 +1,331 @@ +/** + * 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. + * + */ + +/** + * Browser-test helper that simulates IME composition sequences by + * dispatching the same event sequence a real IME produces, combined + * with direct DOM text-node mutations (since untrusted events don't + * trigger browser-level DOM updates). + * + * Intermediate steps (each keystroke): + * keydown(Process) → compositionstart (first only) → compositionupdate → + * beforeinput(insertCompositionText) → DOM mutation → input(isComposing=true) + * + * Commit / cancel: + * DOM mutation (final text or revert) → COMPOSITION_END_COMMAND dispatched + * directly through the editor. This bypasses the platform-specific + * deferral in Lexical's native compositionend handler (Safari sets a flag + * and defers to the next keydown; Firefox defers to the next input event), + * giving the test a single deterministic code path for all browsers. + * + * Between each event, the browser yields to the microtask queue so + * deferred editor updates commit before the next event fires. We + * replicate this with `await flush()` after every event that triggers + * a Lexical `updateEditorSync` (compositionstart, input). + */ + +import {COMPOSITION_END_COMMAND, type LexicalEditor} from 'lexical'; + +export interface CompositionStep { + /** The cumulative composing text at this step. */ + text: string; + /** + * Selection offset within the composing text. + * Defaults to `text.length` (cursor at end). + */ + selectionStart?: number; + selectionEnd?: number; +} + +export interface CompositionSequence { + /** Intermediate composition states (each keystroke). */ + steps: CompositionStep[]; + /** The final committed text. If omitted, uses the last step's text. */ + commitText?: string; + /** + * If true, simulate composition cancellation (Escape) instead of + * committing. The DOM reverts to pre-composition text. + */ + cancel?: boolean; +} + +interface CompositionTarget { + /** The contentEditable root element the editor is attached to. */ + rootElement: HTMLElement; + /** The Lexical editor instance — used to dispatch COMPOSITION_END_COMMAND. */ + editor: LexicalEditor; +} + +function getActiveTextNode(root: HTMLElement): Text | null { + const sel = document.getSelection(); + if (sel && sel.rangeCount > 0) { + const node = sel.focusNode; + if (node instanceof Text && root.contains(node)) { + return node; + } + } + return null; +} + +function dispatchCompositionEvent( + target: HTMLElement, + type: 'compositionstart' | 'compositionupdate' | 'compositionend', + data: string, +): void { + target.dispatchEvent( + new CompositionEvent(type, {bubbles: true, cancelable: true, data}), + ); +} + +function dispatchBeforeInput( + target: HTMLElement, + data: string, + inputType: string, + targetRange?: StaticRange, +): void { + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + data, + inputType, + }); + Object.defineProperty(event, 'getTargetRanges', { + value: () => (targetRange ? [targetRange] : []), + }); + target.dispatchEvent(event); +} + +function dispatchInputEvent( + target: HTMLElement, + data: string, + inputType: string, + isComposing: boolean, +): void { + const event = new InputEvent('input', { + bubbles: true, + cancelable: false, + data, + inputType, + }); + // InputEvent.isComposing is readonly; override it for untrusted events. + Object.defineProperty(event, 'isComposing', {value: isComposing}); + target.dispatchEvent(event); +} + +function setSelectionAt(textNode: Text, start: number, end: number): void { + const sel = document.getSelection(); + if (!sel) { + return; + } + sel.setBaseAndExtent(textNode, start, textNode, end); +} + +function flush(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + +/** + * Mutate the DOM text node to reflect the current composition state, + * replacing the composing region with the new text. + * + * Returns the text node (which may have been created if the target was + * an empty element). + */ +function applyDOMMutation( + root: HTMLElement, + composingText: string, + composingStart: number, + previousComposingLength: number, +): Text { + let textNode = getActiveTextNode(root); + if (!textNode) { + // Empty paragraph — create a text node. + const sel = document.getSelection(); + const focusNode = sel?.focusNode; + if (focusNode instanceof HTMLElement && root.contains(focusNode)) { + // Remove any
placeholder. + const br = focusNode.querySelector('br'); + if (br) { + br.remove(); + } + textNode = document.createTextNode(''); + focusNode.appendChild(textNode); + } else { + throw new Error('compose(): cannot find a suitable insertion point'); + } + } + + const value = textNode.nodeValue || ''; + const before = value.slice(0, composingStart); + const after = value.slice(composingStart + previousComposingLength); + textNode.nodeValue = before + composingText + after; + return textNode; +} + +/** + * Simulate a full IME composition sequence in a browser test. + * + * Usage: + * ```ts + * await compose( + * {rootElement: editor.getRootElement()!}, + * { + * steps: [ + * {text: 'ㅎ'}, + * {text: '하'}, + * {text: '한'}, + * ], + * commitText: '한', + * }, + * ); + * ``` + */ +export async function compose( + target: CompositionTarget, + sequence: CompositionSequence, +): Promise { + const {rootElement} = target; + const {steps, cancel} = sequence; + const commitText = + sequence.commitText ?? + (steps.length > 0 ? steps[steps.length - 1].text : ''); + + if (steps.length === 0) { + return; + } + + let previousComposingLength = 0; + let composingStart = 0; + + // --- intermediate steps --- + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const {text} = step; + const selStart = step.selectionStart ?? text.length; + const selEnd = step.selectionEnd ?? selStart; + + // Each keystroke fires keydown(Process). The first keystroke also + // triggers compositionstart before compositionupdate — this matches + // the real browser sequence where one keydown produces both events. + rootElement.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Process', + keyCode: 229, + }), + ); + + if (i === 0) { + dispatchCompositionEvent(rootElement, 'compositionstart', ''); + await flush(); + + // Re-read the cursor position AFTER compositionstart: Lexical may + // have inserted a ZWSP (format mismatch, element anchor, etc.), + // shifting the actual composing start offset. + const sel = document.getSelection(); + composingStart = sel ? sel.focusOffset : 0; + } + + dispatchCompositionEvent(rootElement, 'compositionupdate', text); + + const rangeNode = getActiveTextNode(rootElement); + const targetRange = + rangeNode != null + ? new StaticRange({ + endContainer: rangeNode, + endOffset: composingStart + previousComposingLength, + startContainer: rangeNode, + startOffset: composingStart, + }) + : undefined; + dispatchBeforeInput( + rootElement, + text, + 'insertCompositionText', + targetRange, + ); + + const textNode = applyDOMMutation( + rootElement, + text, + composingStart, + previousComposingLength, + ); + + setSelectionAt( + textNode, + composingStart + selStart, + composingStart + selEnd, + ); + + previousComposingLength = text.length; + + dispatchInputEvent(rootElement, text, 'insertCompositionText', true); + await flush(); + } + + // --- commit or cancel --- + // Instead of dispatching a DOM compositionend event (which Lexical + // defers on Safari and Firefox), we apply the final DOM mutation and + // then dispatch COMPOSITION_END_COMMAND directly through the editor. + // This gives a single deterministic path on all browsers. + if (cancel) { + rootElement.dispatchEvent( + new KeyboardEvent('keydown', {bubbles: true, key: 'Escape'}), + ); + applyDOMMutation(rootElement, '', composingStart, previousComposingLength); + const textNode = getActiveTextNode(rootElement); + if (textNode) { + setSelectionAt(textNode, composingStart, composingStart); + } + } else { + const textNode = applyDOMMutation( + rootElement, + commitText, + composingStart, + previousComposingLength, + ); + setSelectionAt( + textNode, + composingStart + commitText.length, + composingStart + commitText.length, + ); + } + + const endData = cancel ? '' : commitText; + target.editor.dispatchCommand( + COMPOSITION_END_COMMAND, + new CompositionEvent('compositionend', { + bubbles: true, + cancelable: true, + data: endData, + }), + ); + await flush(); +} + +/** + * Convenience: build a Korean Hangul composition sequence from + * incremental jamo assembly. Each string in `parts` is the cumulative + * composing text at that keystroke. + * + * Example — typing "한": + * ```ts + * korean(['ㅎ', '하', '한']) + * // → {steps: [{text: 'ㅎ'}, {text: '하'}, {text: '한'}], commitText: '한'} + * ``` + */ +export function korean( + parts: string[], + commitText?: string, +): CompositionSequence { + return { + commitText: commitText ?? parts[parts.length - 1], + steps: parts.map(text => ({text})), + }; +} diff --git a/packages/lexical/src/__tests__/unit/LexicalAndroidChromeComposition.test.ts b/packages/lexical/src/__tests__/unit/LexicalAndroidChromeComposition.test.ts index 88540e553c0..e60189f954c 100644 --- a/packages/lexical/src/__tests__/unit/LexicalAndroidChromeComposition.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalAndroidChromeComposition.test.ts @@ -18,16 +18,16 @@ * non-collapsed selection, Android composition latency heuristic). */ +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; import { $createParagraphNode, $createTextNode, $getRoot, COMMAND_PRIORITY_CRITICAL, CONTROLLED_TEXT_INSERTION_COMMAND, - LexicalEditor, } from 'lexical'; -import {createTestEditor} from 'lexical/src/__tests__/utils'; -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import {describe, expect, onTestFinished, test, vi} from 'vitest'; vi.mock('lexical/src/environment', () => ({ CAN_USE_BEFORE_INPUT: true, @@ -42,35 +42,31 @@ vi.mock('lexical/src/environment', () => ({ IS_SAFARI: false, })); -describe('Android Chrome composition — format mismatch ZWSP skip', () => { - let container: HTMLDivElement; - let editor: LexicalEditor; - - beforeEach(() => { - container = document.createElement('div'); - container.setAttribute('data-lexical-editor', 'true'); - container.contentEditable = 'true'; - document.body.appendChild(container); - editor = createTestEditor(); - editor.setRootElement(container); +function mountEditor($initialEditorState: () => void) { + const container = document.createElement('div'); + container.contentEditable = 'true'; + document.body.appendChild(container); + const editor = buildEditorFromExtensions({ + $initialEditorState, + dependencies: [RichTextExtension], + name: 'test', }); - - afterEach(() => { + editor.setRootElement(container); + onTestFinished(() => { editor.setRootElement(null); document.body.removeChild(container); }); + return {container, editor}; +} +describe('Android Chrome composition — format mismatch ZWSP skip', () => { test('compositionStart with format mismatch skips CONTROLLED_TEXT_INSERTION', async () => { - editor.update( - () => { - const textNode = $createTextNode('hello'); - const paragraph = $createParagraphNode().append(textNode); - $getRoot().clear().append(paragraph); - const selection = textNode.selectEnd(); - selection.format = 1; - }, - {discrete: true}, - ); + const {container, editor} = mountEditor(() => { + const textNode = $createTextNode('hello'); + $getRoot().append($createParagraphNode().append(textNode)); + const selection = textNode.selectEnd(); + selection.format = 1; + }); const insertionPayloads: string[] = []; editor.registerCommand( @@ -85,21 +81,17 @@ describe('Android Chrome composition — format mismatch ZWSP skip', () => { container.dispatchEvent( new CompositionEvent('compositionstart', {bubbles: true, data: ''}), ); - await Promise.resolve(); expect(insertionPayloads).toEqual([]); }); test('compositionStart with element anchor still dispatches CONTROLLED_TEXT_INSERTION', async () => { - editor.update( - () => { - const paragraph = $createParagraphNode(); - $getRoot().clear().append(paragraph); - paragraph.select(); - }, - {discrete: true}, - ); + const {container, editor} = mountEditor(() => { + const paragraph = $createParagraphNode(); + $getRoot().append(paragraph); + paragraph.select(); + }); const insertionPayloads: string[] = []; editor.registerCommand( @@ -114,7 +106,6 @@ describe('Android Chrome composition — format mismatch ZWSP skip', () => { container.dispatchEvent( new CompositionEvent('compositionstart', {bubbles: true, data: ''}), ); - await Promise.resolve(); expect(insertionPayloads.length).toBe(1); diff --git a/packages/lexical/src/__tests__/unit/LexicalFirefoxCompositionEndTag.test.ts b/packages/lexical/src/__tests__/unit/LexicalFirefoxCompositionEndTag.test.ts index abd961abf37..1bbd65b1655 100644 --- a/packages/lexical/src/__tests__/unit/LexicalFirefoxCompositionEndTag.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalFirefoxCompositionEndTag.test.ts @@ -15,16 +15,15 @@ * same signal on Firefox. */ -import {registerRichText} from '@lexical/rich-text'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; import { $createParagraphNode, $createTextNode, $getRoot, COMPOSITION_END_TAG, - LexicalEditor, } from 'lexical'; -import {createTestEditor} from 'lexical/src/__tests__/utils'; -import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest'; +import {describe, expect, onTestFinished, test, vi} from 'vitest'; vi.mock('lexical/src/environment', () => ({ CAN_USE_BEFORE_INPUT: true, @@ -40,47 +39,34 @@ vi.mock('lexical/src/environment', () => ({ })); describe('Firefox composition-end tag forwarding', () => { - let container: HTMLDivElement; - let editor: LexicalEditor; - - beforeEach(() => { - container = document.createElement('div'); - container.setAttribute('data-lexical-editor', 'true'); + test('onInput emits COMPOSITION_END_TAG after a deferred compositionend', async () => { + const container = document.createElement('div'); container.contentEditable = 'true'; document.body.appendChild(container); - editor = createTestEditor(); - registerRichText(editor); - editor.setRootElement(container); - }); - - afterEach(() => { - editor.setRootElement(null); - document.body.removeChild(container); - }); - - test('onInput emits COMPOSITION_END_TAG after a deferred compositionend', async () => { - editor.update( - () => { + const editor = buildEditorFromExtensions({ + $initialEditorState: () => { const paragraph = $createParagraphNode().append($createTextNode('-')); - $getRoot().clear().append(paragraph); + $getRoot().append(paragraph); paragraph.selectEnd(); }, - {discrete: true}, - ); + dependencies: [RichTextExtension], + name: 'test', + }); + editor.setRootElement(container); + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); const observedTagSets: string[][] = []; editor.registerUpdateListener(({tags}) => { observedTagSets.push(Array.from(tags)); }); - // Firefox routes compositionend through `isFirefoxEndingComposition`; this - // event alone doesn't run $onCompositionEndImpl yet. container.dispatchEvent( new CompositionEvent('compositionend', {bubbles: true, data: ' '}), ); - // The deferred composition end runs inside this onInput. The fix adds - // COMPOSITION_END_TAG here. const inputEvent = new InputEvent('input', { bubbles: true, data: ' ', @@ -89,7 +75,6 @@ describe('Firefox composition-end tag forwarding', () => { Object.defineProperty(inputEvent, 'isComposing', {value: false}); container.dispatchEvent(inputEvent); - // Update listeners fire on a microtask; let them flush before asserting. await Promise.resolve(); expect( diff --git a/packages/lexical/src/__tests__/unit/LexicalIosKoreanIME.test.ts b/packages/lexical/src/__tests__/unit/LexicalIosKoreanIME.test.ts index 6efa39d13ff..e0bb2d5f917 100644 --- a/packages/lexical/src/__tests__/unit/LexicalIosKoreanIME.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalIosKoreanIME.test.ts @@ -22,7 +22,8 @@ * when on iOS with a non-collapsed targetRange. */ -import {registerRichText} from '@lexical/rich-text'; +import {buildEditorFromExtensions} from '@lexical/extension'; +import {RichTextExtension} from '@lexical/rich-text'; import { $createParagraphNode, $createRangeSelection, @@ -33,21 +34,9 @@ import { $setSelection, LexicalEditor, } from 'lexical'; -import {createTestEditor, invariant} from 'lexical/src/__tests__/utils'; -import { - afterEach, - assert, - beforeEach, - describe, - expect, - test, - vi, -} from 'vitest'; - -// `vi.mock` is hoisted above all imports, so LexicalEvents.ts / -// LexicalConstants.ts observe IS_IOS=true and CAN_USE_BEFORE_INPUT=true. -// Mock the exact module the core imports relatively (`./environment`); the -// `lexical/src/environment` test alias resolves to that same file. +import {invariant} from 'lexical/src/__tests__/utils'; +import {assert, describe, expect, onTestFinished, test, vi} from 'vitest'; + vi.mock('lexical/src/environment', () => ({ CAN_USE_BEFORE_INPUT: true, CAN_USE_DOM: true, @@ -61,9 +50,21 @@ vi.mock('lexical/src/environment', () => ({ IS_SAFARI: false, })); -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- +function mountEditor() { + const container = document.createElement('div'); + container.contentEditable = 'true'; + document.body.appendChild(container); + const editor = buildEditorFromExtensions({ + dependencies: [RichTextExtension], + name: 'test', + }); + editor.setRootElement(container); + onTestFinished(() => { + editor.setRootElement(null); + document.body.removeChild(container); + }); + return {container, editor}; +} function getDOMTextNode(editor: LexicalEditor, textKey: string): Text { const span = editor.getElementByKey(textKey); @@ -76,10 +77,6 @@ function getDOMTextNode(editor: LexicalEditor, textKey: string): Text { return textNode as Text; } -/** - * Creates a mock beforeinput InputEvent whose getTargetRanges() returns - * a single StaticRange built from the provided DOM boundary points. - */ function createBeforeInputEvent( inputType: string, targetRange: StaticRange | null, @@ -89,7 +86,6 @@ function createBeforeInputEvent( cancelable: true, inputType, }); - // jsdom InputEvent does not expose getTargetRanges; patch it manually. Object.defineProperty(event, 'getTargetRanges', { value: () => (targetRange ? [targetRange] : []), }); @@ -110,10 +106,6 @@ function createStaticRange( }); } -/** - * Replaces the editor content with a single paragraph containing `text` - * and places a collapsed selection at `cursorOffset`. Returns the text node key. - */ async function setSingleTextNode( editor: LexicalEditor, text: string, @@ -135,42 +127,13 @@ async function setSingleTextNode( return textKey; } -// --------------------------------------------------------------------------- -// Test suite -// --------------------------------------------------------------------------- - describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () => { - let container: HTMLDivElement; - let editor: LexicalEditor; - - beforeEach(() => { - container = document.createElement('div'); - container.setAttribute('data-lexical-editor', 'true'); - container.contentEditable = 'true'; - document.body.appendChild(container); - editor = createTestEditor(); - registerRichText(editor); - editor.setRootElement(container); - }); - - afterEach(() => { - editor.setRootElement(null); - document.body.removeChild(container); - }); - - // ------------------------------------------------------------------------- - // 1. Unit test: applyDOMRange correctly resolves a range over Korean text - // ------------------------------------------------------------------------- - test('applyDOMRange resolves a range over in-progress Korean jamo', async () => { - // "안녕하" assembled + "ᄉᆞ" (Hangul Choseong Sios U+1109 + - // Jungseong Arae-a U+119E, two composing jamo) — 5 BMP code units total. + const {editor} = mountEditor(); const composingText = '안녕하ᄉᆞ'; const textKey = await setSingleTextNode(editor, composingText, 5); const domText = getDOMTextNode(editor, textKey); - - // The targetRange covers the two composing jamo at [3, 5] const targetRange = createStaticRange(domText, 3, domText, 5); await editor.update(() => { @@ -187,6 +150,7 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () }); test('applyDOMRange + removeText leaves only the assembled syllables', async () => { + const {editor} = mountEditor(); const composingText = '안녕하ᄉᆞ'; const textKey = await setSingleTextNode(editor, composingText, 5); @@ -200,19 +164,13 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () sel.removeText(); }); - await editor.read(() => { + editor.read(() => { expect($getRoot().getTextContent()).toBe('안녕하'); }); }); - // ------------------------------------------------------------------------- - // 2. Integration test: deleteContentBackward event uses targetRange on iOS - // ------------------------------------------------------------------------- - test('deleteContentBackward with non-collapsed targetRange deletes the targetRange text', async () => { - // Simulate the state mid-composition: "안녕하ᄉᆞ" in the editor, - // cursor at end. The IME fires deleteContentBackward targeting [3,5] - // to erase "ᄉᆞ", then will follow up with insertText "세". + const {container, editor} = mountEditor(); const composingText = '안녕하ᄉᆞ'; const textKey = await setSingleTextNode(editor, composingText, 5); @@ -222,19 +180,13 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () container.dispatchEvent(event); - await editor.read(() => { - // The fix should delete only "ᄉᆞ" (the targetRange), not "하" + editor.read(() => { expect($getRoot().getTextContent()).toBe('안녕하'); }); }); test('applyDOMRange with collapsed targetRange leaves selection collapsed — iOS fast path is skipped', async () => { - // The iOS fast path guard is: - // if (IS_IOS && targetRange !== null && !targetRange.collapsed) - // When targetRange IS collapsed, the guard is false and Lexical falls through - // to its default deletion path. This test verifies the guard condition: - // applyDOMRange with a collapsed range produces a collapsed selection, so - // !selection.isCollapsed() is false and the early return is not taken. + const {editor} = mountEditor(); const text = '안녕하세요'; const textKey = await setSingleTextNode(editor, text, 5); @@ -243,34 +195,23 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () await editor.update(() => { const sel = $getSelection(); invariant($isRangeSelection(sel), 'expected RangeSelection'); - // Applying a collapsed range keeps the selection collapsed → - // the iOS fast path's !sel.isCollapsed() check is false → falls through. const collapsedRange = createStaticRange(domText, 5, domText, 5); sel.applyDOMRange(collapsedRange); expect(sel.isCollapsed()).toBe(true); }); - // Text must be unchanged (the iOS fast path did not fire). - await editor.read(() => { + editor.read(() => { expect($getRoot().getTextContent()).toBe('안녕하세요'); }); }); - // ------------------------------------------------------------------------- - // 3. Straddle test: targetRange crosses a text-node boundary - // ------------------------------------------------------------------------- - test('applyDOMRange handles a targetRange that straddles two adjacent text nodes', async () => { - // Simulate a scenario where "녕" at the end of node1 and "하" at the - // start of node2 are involved in a cross-node IME range. - // node1: "안녕" node2: "하세요" + const {editor} = mountEditor(); let key1 = ''; let key2 = ''; await editor.update(() => { const paragraph = $createParagraphNode(); - // Use different styles to prevent Lexical from merging adjacent text nodes - // that share the same format/style (which it normalizes by default). const node1 = $createTextNode('안녕').setStyle('--x:0'); const node2 = $createTextNode('하세요'); paragraph.append(node1, node2); @@ -279,16 +220,13 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () key2 = node2.getKey(); const sel = $createRangeSelection(); - sel.anchor.set(key2, 3, 'text'); // cursor at end of node2 + sel.anchor.set(key2, 3, 'text'); sel.focus.set(key2, 3, 'text'); $setSelection(sel); }); const domText1 = getDOMTextNode(editor, key1); const domText2 = getDOMTextNode(editor, key2); - - // Range spans from offset 1 of node1 (between "안" and "녕") to offset 1 - // of node2 (between "하" and "세") — i.e. the characters "녕하". const straddleRange = createStaticRange(domText1, 1, domText2, 1); await editor.update(() => { @@ -305,8 +243,7 @@ describe('iOS 10-key Korean IME — deleteContentBackward with targetRange', () sel.removeText(); }); - await editor.read(() => { - // "녕" and "하" removed → "안" + "세요" = "안세요" + editor.read(() => { expect($getRoot().getTextContent()).toBe('안세요'); }); }); From c37200b40402c93415154d7bc3f4da5610d68518 Mon Sep 17 00:00:00 2001 From: mayrang Date: Tue, 7 Jul 2026 13:17:23 +0900 Subject: [PATCH 3/4] [lexical-markdown] Feature: Add $generateNodesFromMarkdownString (#8789) Co-authored-by: Bob Ippolito --- .../lexical-markdown/src/MarkdownImport.ts | 108 ++++++--------- .../__tests__/unit/LexicalMarkdown.test.ts | 131 ++++++++++++++++++ packages/lexical-markdown/src/index.ts | 49 ++++++- 3 files changed, 221 insertions(+), 67 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 4f78fbc121f..79e88c16a3d 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -21,8 +21,6 @@ import { $createTabNode, $createTextNode, $findMatchingParent, - $getRoot, - $getSelection, $isElementNode, $isParagraphNode, ElementNode, @@ -40,77 +38,63 @@ export type TextFormatTransformersIndex = Readonly<{ }>; /** - * Renders markdown from a string. The selection is moved to the start after the operation. + * Parses a markdown string and appends the resulting nodes to `container`. + * Does not clear the container or touch the selection — callers handle that. */ -export function createMarkdownImport( +export function $importMarkdownNodes( + markdownString: string, + container: ElementNode, transformers: Transformer[], shouldPreserveNewLines = false, -): (markdownString: string, node?: ElementNode) => void { +): void { const byType = transformersByType(transformers); const textFormatTransformersIndex = createTextFormatTransformersIndex( byType.textFormat, ); + const lines = markdownString.split('\n'); + const linesLength = lines.length; + + for (let i = 0; i < linesLength; i++) { + const lineText = lines[i]; + + const [imported, shiftedIndex] = $importMultiline( + lines, + i, + byType.multilineElement, + container, + ); + + if (imported) { + i = shiftedIndex; + continue; + } - return (markdownString, node) => { - const lines = markdownString.split('\n'); - const linesLength = lines.length; - const root = node || $getRoot(); - root.clear(); - - for (let i = 0; i < linesLength; i++) { - const lineText = lines[i]; - - const [imported, shiftedIndex] = $importMultiline( - lines, - i, - byType.multilineElement, - root, - ); - - if (imported) { - // If a multiline markdown element was imported, we don't want to process the lines that were part of it anymore. - // There could be other sub-markdown elements (both multiline and normal ones) matching within this matched multiline element's children. - // However, it would be the responsibility of the matched multiline transformer to decide how it wants to handle them. - // We cannot handle those, as there is no way for us to know how to maintain the correct order of generated lexical nodes for possible children. - i = shiftedIndex; // Next loop will start from the line after the last line of the multiline element - continue; - } + $importBlocks( + lineText, + container, + byType.element, + textFormatTransformersIndex, + byType.textMatch, + shouldPreserveNewLines, + ); + } - $importBlocks( - lineText, - root, - byType.element, - textFormatTransformersIndex, - byType.textMatch, - shouldPreserveNewLines, - ); + const children = container.getChildren(); + for (const child of children) { + if ( + !shouldPreserveNewLines && + isEmptyParagraph(child) && + container.getChildrenSize() > 1 + ) { + child.remove(); + continue; } - - const children = root.getChildren(); - for (const child of children) { - // By default, removing empty paragraphs as md does not really - // allow empty lines and uses them as delimiter. - // If you need empty lines set shouldPreserveNewLines = true. - if ( - !shouldPreserveNewLines && - isEmptyParagraph(child) && - root.getChildrenSize() > 1 - ) { - child.remove(); - continue; + if ($isElementNode(child)) { + for (const textNode of child.getAllTextNodes()) { + $normalizeMarkdownTextNode(textNode); } - // Convert all '\t' into TabNode. - if ($isElementNode(child)) { - for (const textNode of child.getAllTextNodes()) { - $normalizeMarkdownTextNode(textNode); - } - } - } - - if ($getSelection() !== null) { - root.selectStart(); } - }; + } } /** @@ -262,7 +246,7 @@ function $importBlocks( // If no transformer found and we left with original paragraph node // can check if its content can be appended to the previous node // if it's a paragraph, quote or list - if (elementNode.isAttached() && lineText.length > 0) { + if (elementNode.getParent() !== null && lineText.length > 0) { const previousNode = elementNode.getPreviousSibling(); if ( !shouldPreserveNewLines && // Only append if we're not preserving newlines diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index df804995074..a49d1b74f5f 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -20,6 +20,7 @@ import { $convertFromMarkdownString, $convertSelectionToMarkdownString, $convertToMarkdownString, + $generateNodesFromMarkdownString, CHECK_LIST, CODE, ElementTransformer, @@ -2868,3 +2869,133 @@ describe('Ordered list start adjustment (#8677)', () => { ); }); }); + +describe('$generateNodesFromMarkdownString', () => { + function createTestEditor() { + return createHeadlessEditor({ + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + LinkNode, + ], + }); + } + + it('returns nodes without modifying the root', () => { + const editor = createTestEditor(); + + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('existing'))); + }, + {discrete: true}, + ); + + let nodes: ReturnType = []; + editor.update( + () => { + nodes = $generateNodesFromMarkdownString( + '# Heading\n\nParagraph', + TRANSFORMERS, + ); + }, + {discrete: true}, + ); + + expect(nodes).toHaveLength(2); + expect(nodes[0].getType()).toBe('heading'); + expect(nodes[1].getType()).toBe('paragraph'); + + expect(editor.read(() => $getRoot().getTextContent())).toBe('existing'); + }); + + it('produces the same nodes as $convertFromMarkdownString', () => { + const md = '# Title\n\n- item 1\n- item 2\n\n> quote\n\n```\ncode\n```'; + + const convertEditor = createTestEditor(); + convertEditor.update(() => $convertFromMarkdownString(md, TRANSFORMERS), { + discrete: true, + }); + const convertHtml = convertEditor.read(() => + $generateHtmlFromNodes(convertEditor), + ); + + const generateEditor = createTestEditor(); + generateEditor.update( + () => { + const nodes = $generateNodesFromMarkdownString(md, TRANSFORMERS); + $getRoot() + .clear() + .append(...nodes); + }, + {discrete: true}, + ); + const generateHtml = generateEditor.read(() => + $generateHtmlFromNodes(generateEditor), + ); + + expect(generateHtml).toBe(convertHtml); + }); + + it('returned nodes can be inserted at selection', () => { + const editor = createTestEditor(); + + editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('before')), + $createParagraphNode().append($createTextNode('after')), + ); + }, + {discrete: true}, + ); + + editor.update( + () => { + const root = $getRoot(); + root.select(1, 1); + const nodes = $generateNodesFromMarkdownString( + '**bold**', + TRANSFORMERS, + ); + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertNodes(nodes); + } + }, + {discrete: true}, + ); + + const html = editor.read(() => $generateHtmlFromNodes(editor)); + expect(html).toContain('before'); + expect(html).toContain(' { + const editor = createTestEditor(); + + let nodes: ReturnType = []; + editor.update( + () => { + nodes = $generateNodesFromMarkdownString( + 'line 1\nline 2', + TRANSFORMERS, + false, + true, + ); + }, + {discrete: true}, + ); + + expect(nodes).toHaveLength(1); + expect(nodes[0].getType()).toBe('paragraph'); + }); +}); diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index 6b683ef61f9..5b07a34bb4c 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -13,15 +13,20 @@ import type { TextMatchTransformer, Transformer, } from './MarkdownTransformers'; -import type {BaseSelection, ElementNode} from 'lexical'; +import type {BaseSelection, ElementNode, LexicalNode} from 'lexical'; -import {$isRangeSelection} from 'lexical'; +import { + $getRoot, + $getSelection, + $isRangeSelection, + ArtificialNode__DO_NOT_USE, +} from 'lexical'; import { createMarkdownExport, createSelectionMarkdownExport, } from './MarkdownExport'; -import {createMarkdownImport} from './MarkdownImport'; +import {$importMarkdownNodes} from './MarkdownImport'; import {registerMarkdownShortcuts} from './MarkdownShortcuts'; import { BOLD_ITALIC_STAR, @@ -65,11 +70,44 @@ function $convertFromMarkdownString( const sanitizedMarkdown = shouldPreserveNewLines ? markdown : normalizeMarkdown(markdown, shouldMergeAdjacentLines); - const importMarkdown = createMarkdownImport( + const root = node || $getRoot(); + root.clear(); + $importMarkdownNodes( + sanitizedMarkdown, + root, + transformers, + shouldPreserveNewLines, + ); + if ($getSelection() !== null) { + root.selectStart(); + } +} + +/** + * Parses a markdown string and returns the resulting nodes as an array, + * without modifying the document tree or selection. The returned nodes can be + * inserted at an arbitrary position via `selection.insertNodes()`. + * + * @param {boolean} [shouldPreserveNewLines] By setting this to true, new lines will be preserved between conversions + * @param {boolean} [shouldMergeAdjacentLines] By setting this to true, adjacent non empty lines will be merged according to commonmark spec: https://spec.commonmark.org/0.24/#example-177. Not applicable if shouldPreserveNewLines = true. + */ +function $generateNodesFromMarkdownString( + markdown: string, + transformers: Transformer[] = TRANSFORMERS, + shouldPreserveNewLines = false, + shouldMergeAdjacentLines = false, +): LexicalNode[] { + const sanitizedMarkdown = shouldPreserveNewLines + ? markdown + : normalizeMarkdown(markdown, shouldMergeAdjacentLines); + const container = new ArtificialNode__DO_NOT_USE(); + $importMarkdownNodes( + sanitizedMarkdown, + container, transformers, shouldPreserveNewLines, ); - return importMarkdown(sanitizedMarkdown, node); + return container.getChildren(); } /** @@ -109,6 +147,7 @@ export { $convertFromMarkdownString, $convertSelectionToMarkdownString, $convertToMarkdownString, + $generateNodesFromMarkdownString, BOLD_ITALIC_STAR, BOLD_ITALIC_UNDERSCORE, BOLD_STAR, From c07e832ab8dfee640e5348335f7210a1edac5917 Mon Sep 17 00:00:00 2001 From: mayrang Date: Tue, 7 Jul 2026 13:19:10 +0900 Subject: [PATCH 4/4] [lexical] Chore: Add missing Flow type declarations (#8799) --- packages/lexical/flow/Lexical.js.flow | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 5d2ac0072a2..580edeae91f 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -720,7 +720,16 @@ declare class _Point { set(key: NodeKey, offset: number, type: 'text' | 'element', onlyIfChanged?: boolean): void; } +declare export function $createPoint( + key: NodeKey, + offset: number, + type: 'text' | 'element', +): PointType; declare export function $createRangeSelection(): RangeSelection; +declare export function $createRangeSelectionFromDom( + domSelection: Selection | null, + editor: LexicalEditor, +): null | RangeSelection; declare export function $createNodeSelection(): NodeSelection; declare export function $isRangeSelection( x: ?unknown, @@ -730,9 +739,14 @@ declare export function $formatText( formatType: TextFormatType, alignWithFormat?: number | null, ): void; +declare export function $generateNodesFromRawText( + text: string, +): (TextNode | LineBreakNode)[]; declare export function $getSelection(): null | BaseSelection; +declare export function $getTextContent(): string; declare export function $getPreviousSelection(): null | BaseSelection; declare export function $insertNodes(nodes: LexicalNode[]): void; +declare export function $selectAll(selection?: RangeSelection | null): RangeSelection; declare export function $getCharacterOffsets( selection: BaseSelection, ): [number, number]; @@ -1192,8 +1206,40 @@ declare export function $hasAncestor( child: LexicalNode, targetNode: LexicalNode, ): boolean; +declare export function $createChildrenArray( + element: ElementNode, + nodeMap: null | NodeMap, +): NodeKey[]; +declare export function $findMatchingParent( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => node is T, +): T | null; +declare export function $findMatchingParent( + startingNode: LexicalNode, + findFn: (node: LexicalNode) => boolean, +): LexicalNode | null; +declare export function $getNodeFromDOMNode( + dom: Node, + editorState?: EditorState, +): LexicalNode | null; +declare export function $isTokenOrSegmented(node: TextNode): boolean; +declare export function $isTokenOrTab(node: TextNode): boolean; +declare export function $splitNode( + node: ElementNode, + offset: number, +): [ElementNode | null, ElementNode]; +declare export function $setDirectionFromDOM( + node: T, + domNode: HTMLElement, +): T; +declare export function $setFormatFromDOM( + node: T, + domNode: HTMLElement, +): T; declare export function $cloneWithProperties(node: T): T; +declare export function $cloneWithPropertiesEphemeral(node: T): T; declare export function $copyNode(node: T, skipReset?: boolean): T; +declare export function $getDocument(): Document; declare export function $getEditor(): LexicalEditor; declare export function $getEditorDOMRenderConfig( editor?: LexicalEditor, @@ -1310,6 +1356,9 @@ declare export function getComposedEventTarget(event: Event): EventTarget | null * LexicalNormalization */ +declare export function $normalizeSelection( + selection: RangeSelection, +): RangeSelection; declare export function $normalizeSelection__EXPERIMENTAL( selection: RangeSelection, ): RangeSelection;