diff --git a/package-lock.json b/package-lock.json index 8c7a10a01af..d8fb0f29353 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25539,11 +25539,12 @@ ] }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -36568,6 +36569,7 @@ "version": "0.17.1", "license": "MIT", "dependencies": { + "@lexical/clipboard": "0.17.1", "@lexical/utils": "0.17.1", "lexical": "0.17.1" } @@ -36626,6 +36628,7 @@ "license": "MIT", "dependencies": { "@lexical/offset": "0.17.1", + "@lexical/selection": "0.17.1", "lexical": "0.17.1" }, "peerDependencies": { @@ -41183,6 +41186,7 @@ "@lexical/table": { "version": "file:packages/lexical-table", "requires": { + "@lexical/clipboard": "0.17.1", "@lexical/utils": "0.17.1", "lexical": "0.17.1" } @@ -41232,6 +41236,7 @@ "version": "file:packages/lexical-yjs", "requires": { "@lexical/offset": "0.17.1", + "@lexical/selection": "0.17.1", "lexical": "0.17.1" } }, @@ -54792,11 +54797,11 @@ "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==" }, "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "requires": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" } }, diff --git a/packages/lexical-code/src/CodeHighlighterPrism.ts b/packages/lexical-code/src/CodeHighlighterPrism.ts index 3f1d679cbe1..9ec68588456 100644 --- a/packages/lexical-code/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code/src/CodeHighlighterPrism.ts @@ -24,9 +24,14 @@ import 'prismjs/components/prism-swift'; import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-java'; import 'prismjs/components/prism-cpp'; +import {CAN_USE_DOM} from 'shared/canUseDOM'; declare global { interface Window { Prism: typeof import('prismjs'); } } + +export const Prism: typeof import('prismjs') = CAN_USE_DOM + ? window.Prism + : (global as unknown as {Prism: typeof import('prismjs')}).Prism; diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index 49af055d28d..728e23e64b1 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -22,8 +22,6 @@ import type { TabNode, } from 'lexical'; -import './CodeHighlighterPrism'; - import {addClassNamesToElement, isHTMLElement} from '@lexical/utils'; import { $applyNodeReplacement, @@ -35,6 +33,7 @@ import { ElementNode, } from 'lexical'; +import {Prism} from './CodeHighlighterPrism'; import { $createCodeHighlightNode, $isCodeHighlightNode, @@ -53,7 +52,7 @@ const isLanguageSupportedByPrism = ( ): boolean => { try { // eslint-disable-next-line no-prototype-builtins - return language ? window.Prism.languages.hasOwnProperty(language) : false; + return language ? Prism.languages.hasOwnProperty(language) : false; } catch { return false; } diff --git a/packages/lexical-html/src/__tests__/unit/LexicalHtml.test.ts b/packages/lexical-html/src/__tests__/unit/LexicalHtml.test.ts index 5583cf94f5a..8759603419a 100644 --- a/packages/lexical-html/src/__tests__/unit/LexicalHtml.test.ts +++ b/packages/lexical-html/src/__tests__/unit/LexicalHtml.test.ts @@ -6,9 +6,6 @@ * */ -//@ts-ignore-next-line -import type {RangeSelection} from 'lexical'; - import {CodeNode} from '@lexical/code'; import {createHeadlessEditor} from '@lexical/headless'; import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; @@ -20,6 +17,8 @@ import { $createRangeSelection, $createTextNode, $getRoot, + ParagraphNode, + RangeSelection, } from 'lexical'; describe('HTML', () => { @@ -212,4 +211,54 @@ describe('HTML', () => { '

Hello world!

', ); }); + + test('It should output correctly nodes whose export is DocumentFragment', () => { + const editor = createHeadlessEditor({ + html: { + export: new Map([ + [ + ParagraphNode, + () => { + const element = document.createDocumentFragment(); + return { + element, + }; + }, + ], + ]), + }, + nodes: [], + }); + + editor.update( + () => { + const root = $getRoot(); + const p1 = $createParagraphNode(); + const text1 = $createTextNode('Hello'); + p1.append(text1); + const p2 = $createParagraphNode(); + const text2 = $createTextNode('World'); + p2.append(text2); + root.append(p1).append(p2); + // Root + // - ParagraphNode + // -- TextNode "Hello" + // - ParagraphNode + // -- TextNode "World" + }, + { + discrete: true, + }, + ); + + let html = ''; + + editor.update(() => { + html = $generateHtmlFromNodes(editor); + }); + + expect(html).toBe( + 'HelloWorld', + ); + }); }); diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 2975315cc35..732bab44048 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -29,6 +29,7 @@ import { $isTextNode, ArtificialNode__DO_NOT_USE, ElementNode, + isDocumentFragment, isInlineDomNode, } from 'lexical'; @@ -147,7 +148,7 @@ function $appendNodesToHTML( } if (shouldInclude && !shouldExclude) { - if (isHTMLElement(element)) { + if (isHTMLElement(element) || isDocumentFragment(element)) { element.append(fragment); } parentElement.append(element); @@ -155,7 +156,11 @@ function $appendNodesToHTML( if (after) { const newElement = after.call(target, element); if (newElement) { - element.replaceWith(newElement); + if (isDocumentFragment(element)) { + element.replaceChildren(newElement); + } else { + element.replaceWith(newElement); + } } } } else { diff --git a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow index 7318dda1171..4ceb12cffaa 100644 --- a/packages/lexical-markdown/flow/LexicalMarkdown.js.flow +++ b/packages/lexical-markdown/flow/LexicalMarkdown.js.flow @@ -17,6 +17,7 @@ import type { export type Transformer = | ElementTransformer + | MultilineElementTransformer | TextFormatTransformer | TextMatchTransformer; @@ -32,10 +33,34 @@ export type ElementTransformer = { children: Array, match: Array, isImport: boolean, - ) => void, + ) => boolean | void, type: 'element', }; +export type MultilineElementTransformer = { + dependencies: Array>; + export?: ( + node: LexicalNode, + traverseChildren: (node: ElementNode) => string, + ) => string | null; + regExpStart: RegExp; + regExpEnd?: + | RegExp + | { + optional?: true; + regExp: RegExp; + }; + replace: ( + rootNode: ElementNode, + children: Array | null, + startMatch: Array, + endMatch: Array | null, + linesInBetween: Array | null, + isImport: boolean, + ) => boolean | void; + type: 'multiline-element'; +}; + export type TextFormatTransformer = $ReadOnly<{ format: $ReadOnlyArray, tag: string, @@ -90,7 +115,7 @@ declare export var ITALIC_UNDERSCORE: TextFormatTransformer; declare export var STRIKETHROUGH: TextFormatTransformer; declare export var UNORDERED_LIST: ElementTransformer; -declare export var CODE: ElementTransformer; +declare export var CODE: MultilineElementTransformer; declare export var HEADING: ElementTransformer; declare export var ORDERED_LIST: ElementTransformer; declare export var QUOTE: ElementTransformer; diff --git a/packages/lexical-markdown/src/MarkdownShortcuts.ts b/packages/lexical-markdown/src/MarkdownShortcuts.ts index 353fbdd954f..c05295acb07 100644 --- a/packages/lexical-markdown/src/MarkdownShortcuts.ts +++ b/packages/lexical-markdown/src/MarkdownShortcuts.ts @@ -403,7 +403,7 @@ export function registerMarkdownShortcuts( if ( type === 'element' || type === 'text-match' || - type === 'multilineElement' + type === 'multiline-element' ) { const dependencies = transformer.dependencies; for (const node of dependencies) { diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index fc0662726ae..37e3d4b8f6d 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -127,7 +127,7 @@ export type MultilineElementTransformer = { */ isImport: boolean, ) => boolean | void; - type: 'multilineElement'; + type: 'multiline-element'; }; export type TextFormatTransformer = Readonly<{ @@ -153,6 +153,18 @@ export type TextMatchTransformer = Readonly<{ type: 'text-match'; }>; +const ORDERED_LIST_REGEX = /^(\s*)(\d{1,})\.\s/; +const UNORDERED_LIST_REGEX = /^(\s*)[-*+]\s/; +const CHECK_LIST_REGEX = /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i; +const HEADING_REGEX = /^(#{1,6})\s/; +const QUOTE_REGEX = /^>\s/; +const CODE_START_REGEX = /^[ \t]*```(\w+)?/; +const CODE_END_REGEX = /[ \t]*```$/; +const CODE_SINGLE_LINE_REGEX = + /^[ \t]*```[^`]+(?:(?:`{1,2}|`{4,})[^`]+)*```(?:[^`]|$)/; +const TABLE_ROW_REG_EXP = /^(?:\|)(.+)(?:\|)\s?$/; +const TABLE_ROW_DIVIDER_REG_EXP = /^(\| ?:?-*:? ?)+\|\s?$/; + const createBlockNode = ( createNode: (match: Array) => ElementNode, ): ElementTransformer['replace'] => { @@ -266,7 +278,7 @@ export const HEADING: ElementTransformer = { const level = Number(node.getTag().slice(1)); return '#'.repeat(level) + ' ' + exportChildren(node); }, - regExp: /^(#{1,6})\s/, + regExp: HEADING_REGEX, replace: createBlockNode((match) => { const tag = ('h' + match[1].length) as HeadingTagType; return $createHeadingNode(tag); @@ -288,7 +300,7 @@ export const QUOTE: ElementTransformer = { } return output.join('\n'); }, - regExp: /^>\s/, + regExp: QUOTE_REGEX, replace: (parentNode, children, _match, isImport) => { if (isImport) { const previousNode = parentNode.getPreviousSibling(); @@ -328,9 +340,9 @@ export const CODE: MultilineElementTransformer = { }, regExpEnd: { optional: true, - regExp: /[ \t]*```$/, + regExp: CODE_END_REGEX, }, - regExpStart: /^[ \t]*```(\w+)?/, + regExpStart: CODE_START_REGEX, replace: ( rootNode, children, @@ -391,7 +403,7 @@ export const CODE: MultilineElementTransformer = { })(rootNode, children, startMatch, isImport); } }, - type: 'multilineElement', + type: 'multiline-element', }; export const UNORDERED_LIST: ElementTransformer = { @@ -399,7 +411,7 @@ export const UNORDERED_LIST: ElementTransformer = { export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, - regExp: /^(\s*)[-*+]\s/, + regExp: UNORDERED_LIST_REGEX, replace: listReplace('bullet'), type: 'element', }; @@ -409,7 +421,7 @@ export const CHECK_LIST: ElementTransformer = { export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, - regExp: /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i, + regExp: CHECK_LIST_REGEX, replace: listReplace('check'), type: 'element', }; @@ -419,7 +431,7 @@ export const ORDERED_LIST: ElementTransformer = { export: (node, exportChildren) => { return $isListNode(node) ? listExport(node, exportChildren, 0) : null; }, - regExp: /^(\s*)(\d{1,})\.\s/, + regExp: ORDERED_LIST_REGEX, replace: listReplace('number'), type: 'element', }; @@ -519,3 +531,55 @@ export const LINK: TextMatchTransformer = { trigger: ')', type: 'text-match', }; + +export function normalizeMarkdown(input: string): string { + const lines = input.split('\n'); + let inCodeBlock = false; + const sanitizedLines: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lastLine = sanitizedLines[sanitizedLines.length - 1]; + + // Code blocks of ```single line``` don't toggle the inCodeBlock flag + if (CODE_SINGLE_LINE_REGEX.test(line)) { + sanitizedLines.push(line); + continue; + } + + // Detect the start or end of a code block + if (CODE_START_REGEX.test(line) || CODE_END_REGEX.test(line)) { + inCodeBlock = !inCodeBlock; + sanitizedLines.push(line); + continue; + } + + // If we are inside a code block, keep the line unchanged + if (inCodeBlock) { + sanitizedLines.push(line); + continue; + } + + // In markdown the concept of "empty paragraphs" does not exist. + // Blocks must be separated by an empty line. Non-empty adjacent lines must be merged. + if ( + line === '' || + lastLine === '' || + !lastLine || + HEADING_REGEX.test(lastLine) || + HEADING_REGEX.test(line) || + QUOTE_REGEX.test(line) || + ORDERED_LIST_REGEX.test(line) || + UNORDERED_LIST_REGEX.test(line) || + CHECK_LIST_REGEX.test(line) || + TABLE_ROW_REG_EXP.test(line) || + TABLE_ROW_DIVIDER_REG_EXP.test(line) + ) { + sanitizedLines.push(line); + } else { + sanitizedLines[sanitizedLines.length - 1] = lastLine + line; + } + } + + return sanitizedLines.join('\n'); +} diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index 421394fcbf1..a18318f0ae6 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -22,7 +22,10 @@ import { Transformer, TRANSFORMERS, } from '../..'; -import {MultilineElementTransformer} from '../../MarkdownTransformers'; +import { + MultilineElementTransformer, + normalizeMarkdown, +} from '../../MarkdownTransformers'; // Matches html within a mdx file const MDX_HTML_TRANSFORMER: MultilineElementTransformer = { @@ -52,7 +55,7 @@ const MDX_HTML_TRANSFORMER: MultilineElementTransformer = { } return false; // Run next transformer }, - type: 'multilineElement', + type: 'multiline-element', }; describe('Markdown', () => { @@ -92,19 +95,36 @@ describe('Markdown', () => { html: '
Hello world
', md: '###### Hello world', }, + { + // Multiline paragraphs: https://spec.commonmark.org/dingus/?text=Hello%0Aworld%0A! + html: '

Helloworld!

', + md: ['Hello', 'world', '!'].join('\n'), + skipExport: true, + }, { // Multiline paragraphs - html: '

Hello
world
!

', + // TO-DO: It would be nice to support also hard line breaks (
) as \ or double spaces + // See https://spec.commonmark.org/0.31.2/#hard-line-breaks. + // Example: '

Hello\\\nworld\\\n!

', + html: '

Hello
world
!

', md: ['Hello', 'world', '!'].join('\n'), + skipImport: true, }, { html: '
Hello
world!
', md: '> Hello\n> world!', }, + // TO-DO:
should be preserved + // { + // html: '
  • Hello
  • world
    !
    !
', + // md: '- Hello\n- world
!
!', + // skipImport: true, + // }, { - // Multiline list items - html: '
  • Hello
  • world
    !
    !
', + // Multiline list items: https://spec.commonmark.org/dingus/?text=-%20Hello%0A-%20world%0A!%0A! + html: '
  • Hello
  • world!!
', md: '- Hello\n- world\n!\n!', + skipExport: true, }, { html: '
  • Hello
  • world
', @@ -182,6 +202,11 @@ describe('Markdown', () => { html: '

Hello world!

', md: '*Hello **world**!*', }, + { + html: '

hello
world

', + md: 'hello\nworld', + shouldPreserveNewLines: true, + }, { html: '

Hello




world!

', md: '# Hello\n\n\n\n**world**!', @@ -274,8 +299,8 @@ describe('Markdown', () => { skipExport: true, }, { - // Import only: multiline quote will be prefixed with ">" on each line during export - html: '
Hello
world
!
', + // https://spec.commonmark.org/dingus/?text=%3E%20Hello%0Aworld%0A! + html: '
Helloworld!
', md: '> Hello\nworld\n!', skipExport: true, }, @@ -298,8 +323,9 @@ describe('Markdown', () => { }, { customTransformers: [MDX_HTML_TRANSFORMER], - html: '

Some HTML in mdx:

From HTML: Line 1\nSome Text
', + html: '

Some HTML in mdx:

From HTML: Line 1Some Text
', md: 'Some HTML in mdx:\n\nLine 1\nSome Text', + skipExport: true, }, ]; @@ -407,3 +433,78 @@ describe('Markdown', () => { }); } }); + +describe('normalizeMarkdown', () => { + it('should combine lines separated by a single \n unless they are in a codeblock', () => { + const markdown = ` +A1 +A2 + +A3 + +\`\`\`md +B1 +B2 + +B3 +\`\`\` + +C1 +C2 + +C3 + +\`\`\`js +D1 +D2 + +D3 +\`\`\` + +\`\`\`single line code\`\`\` + +E1 +E2 + +E3 +`; + expect(normalizeMarkdown(markdown)).toBe(` +A1A2 + +A3 + +\`\`\`md +B1 +B2 + +B3 +\`\`\` + +C1C2 + +C3 + +\`\`\`js +D1 +D2 + +D3 +\`\`\` + +\`\`\`single line code\`\`\` + +E1E2 + +E3 +`); + }); + + it('tables', () => { + const markdown = ` +| a | b | +| --- | --- | +| c | d | +`; + expect(normalizeMarkdown(markdown)).toBe(markdown); + }); +}); diff --git a/packages/lexical-markdown/src/index.ts b/packages/lexical-markdown/src/index.ts index dac5b260478..ec32900fce5 100644 --- a/packages/lexical-markdown/src/index.ts +++ b/packages/lexical-markdown/src/index.ts @@ -31,6 +31,7 @@ import { ITALIC_STAR, ITALIC_UNDERSCORE, LINK, + normalizeMarkdown, ORDERED_LIST, QUOTE, STRIKETHROUGH, @@ -82,11 +83,14 @@ function $convertFromMarkdownString( node?: ElementNode, shouldPreserveNewLines = false, ): void { + const sanitizedMarkdown = shouldPreserveNewLines + ? markdown + : normalizeMarkdown(markdown); const importMarkdown = createMarkdownImport( transformers, shouldPreserveNewLines, ); - return importMarkdown(markdown, node); + return importMarkdown(sanitizedMarkdown, node); } /** diff --git a/packages/lexical-markdown/src/utils.ts b/packages/lexical-markdown/src/utils.ts index 812d61e0269..f2cf71c04e1 100644 --- a/packages/lexical-markdown/src/utils.ts +++ b/packages/lexical-markdown/src/utils.ts @@ -432,7 +432,7 @@ export function transformersByType(transformers: Array): Readonly<{ return { element: (byType.element || []) as Array, - multilineElement: (byType.multilineElement || + multilineElement: (byType['multiline-element'] || []) as Array, textFormat: (byType['text-format'] || []) as Array, textMatch: (byType['text-match'] || []) as Array, diff --git a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs index 28d18df4ce5..efe46564db1 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -43,6 +43,11 @@ test.describe('HTML Tables CopyAndPaste', () => { page, html` + + + + +

{ page, html` + + + + +

{ page, html` + + + + +

{


+ + + + + +
{ html`


+ + + + + +
@@ -531,6 +558,10 @@ test.describe('HTML Tables CopyAndPaste', () => { 123

+ + + +

@@ -646,6 +677,13 @@ test.describe('HTML Tables CopyAndPaste', () => { 123

+ + + + + + +

diff --git a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs index a0b460c8225..3a856bd19f3 100644 --- a/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Indentation.spec.mjs @@ -106,6 +106,9 @@ test.describe('Identation', () => {


+ + +
{

+ + +
{

+ + +
{

+ + +
{


+ + +
{ ); }); - test(`Can create a link with some text after, insert paragraph, then backspace, it should merge correctly`, async ({ - page, - }) => { - await focusEditor(page); - await page.keyboard.type(' abc def '); - await moveLeft(page, 5); - await selectCharacters(page, 'left', 3); + test( + `Can create a link with some text after, insert paragraph, then backspace, it should merge correctly`, + { + tag: '@flaky', + }, + async ({page}) => { + await focusEditor(page); + await page.keyboard.type(' abc def '); + await moveLeft(page, 5); + await selectCharacters(page, 'left', 3); - // link - await click(page, '.link'); - await click(page, '.link-confirm'); + // link + await click(page, '.link'); + await click(page, '.link-confirm'); - await assertHTML( - page, - html` -

- - - abc - - def -

- `, - ); + await assertHTML( + page, + html` +

+ + + abc + + def +

+ `, + ); - await moveLeft(page, 1); - await moveRight(page, 2); - await page.keyboard.press('Enter'); + await moveLeft(page, 1); + await moveRight(page, 2); + await page.keyboard.press('Enter'); - await assertHTML( - page, - html` -

- - - ab - -

-

- - c - - def -

- `, - ); + await assertHTML( + page, + html` +

+ + + ab + +

+

+ + c + + def +

+ `, + ); - await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); - await assertHTML( - page, - html` -

- - - ab - - - c - - def -

- `, - ); - }); + await assertHTML( + page, + html` +

+ + + ab + + + c + + def +

+ `, + ); + }, + ); test(`Can create a link then replace it with plain text`, async ({page}) => { await focusEditor(page); diff --git a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs index b434d62be55..505b038ce28 100644 --- a/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Markdown.spec.mjs @@ -1310,7 +1310,6 @@ const IMPORTED_MARKDOWN_HTML = html` bold italic strikethrough text, -
@@ -1408,9 +1407,7 @@ const IMPORTED_MARKDOWN_HTML = html` dir="ltr"> Blockquotes text goes here
- And second -
- line after + And secondline after
- And can be nested -
- and multiline as well + + And can be nested and multiline as well + diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index c1999142254..5addcdfbc1a 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -26,6 +26,7 @@ import { assertSelection, assertTableSelectionCoordinates, click, + createHumanReadableSelection, evaluate, expect, focusEditor, @@ -398,6 +399,10 @@ test.describe.parallel('Selection', () => { abc

+ + + +
@@ -671,28 +676,115 @@ test.describe.parallel('Selection', () => { const lastCellText = lastCell.locator('span'); const tripleClickDelay = 50; await lastCellText.click({clickCount: 3, delay: tripleClickDelay}); - const anchorPath = [1, 0, 1, 0]; - // Only the last cell should be selected, and not the entire docuemnt if (browserName === 'firefox') { - // Firefox selects the p > span > #text node - await assertSelection(page, { - anchorOffset: 0, - anchorPath: [...anchorPath, 0, 0], - focusOffset: cellText.length, - focusPath: [...anchorPath, 0, 0], - }); + // Firefox correctly selects the last cell + full text content, unlike Chromium which selects the first cell + const expectedSelection = createHumanReadableSelection( + 'the full text of the last cell in the table', + { + anchorOffset: {desc: 'beginning of cell', value: 0}, + anchorPath: [ + {desc: 'index of table in root', value: 1}, + {desc: 'first table row', value: 1}, + {desc: 'second cell', value: 1}, + {desc: 'first paragraph', value: 0}, + {desc: 'first span', value: 0}, + {desc: 'beginning of text', value: 0}, + ], + focusOffset: {desc: 'full text length', value: cellText.length}, + focusPath: [ + {desc: 'index of table in root', value: 1}, + {desc: 'first table row', value: 1}, + {desc: 'second cell', value: 1}, + {desc: 'first paragraph', value: 0}, + {desc: 'first span', value: 0}, + {desc: 'beginning of text', value: 0}, + ], + }, + ); + await assertSelection(page, expectedSelection); } else { - // Other browsers select the p - await assertSelection(page, { - anchorOffset: 0, - anchorPath, - focusOffset: 1, - focusPath: anchorPath, - }); + // Only the first cell should be selected, and not the entire document + // Ideally the last cell should be selected since that was what was triple-clicked, + // but due to the complex selection logic involved, this was the best that could be done. + // The goal ultimately was to prevent dangerous whole document selection. + // Getting the last cell precisely selected can be done at a later point. + const expectedSelection = createHumanReadableSelection( + 'cursor at beginning of the first cell in table', + { + anchorOffset: {desc: 'beginning of cell', value: 0}, + anchorPath: [ + {desc: 'index of table in root', value: 1}, + {desc: 'first table row', value: 1}, + {desc: 'first cell', value: 0}, + {desc: 'beginning of text', value: 0}, + ], + focusOffset: {desc: 'beginning of text', value: 0}, + focusPath: [ + {desc: 'index of table in root', value: 1}, + {desc: 'first table row', value: 1}, + {desc: 'first cell', value: 0}, + {desc: 'beginning of text', value: 0}, + ], + }, + ); + + await assertSelection(page, expectedSelection); } }); + /** + * Dragging down from a table cell onto paragraph text below the table should select the entire table + * and select the paragraph text below the table. + */ + test('Selecting table cell then dragging to outside of table should select entire table', async ({ + page, + isPlainText, + isCollab, + browserName, + legacyEvents, + }) => { + test.skip(isPlainText || isCollab); + + await focusEditor(page); + await insertTable(page, 1, 2); + await moveToEditorEnd(page); + + const endParagraphText = 'Some text'; + await page.keyboard.type(endParagraphText); + + const lastCell = page.locator( + '.PlaygroundEditorTheme__tableCell:last-child', + ); + await lastCell.click(); + await page.keyboard.type('Foo'); + + // Move the mouse to the last cell + await lastCell.hover(); + await page.mouse.down(); + // Move the mouse to the end of the document + await page.mouse.move(500, 500); + + const expectedSelection = createHumanReadableSelection( + 'the full table from beginning to the end of the text in the last cell', + { + anchorOffset: {desc: 'beginning of cell', value: 0}, + anchorPath: [ + {desc: 'index of table in root', value: 1}, + {desc: 'first table row', value: 1}, + {desc: 'first cell', value: 0}, + ], + focusOffset: {desc: 'full text length', value: endParagraphText.length}, + focusPath: [ + {desc: 'index of last paragraph', value: 2}, + {desc: 'index of first span', value: 0}, + {desc: 'index of text block', value: 0}, + ], + }, + ); + await assertSelection(page, expectedSelection); + }); + test('Can persist the text format from the paragraph', async ({ page, isPlainText, @@ -937,7 +1029,7 @@ test.describe.parallel('Selection', () => { anchorOffset: 0, anchorPath: [0], focusOffset: 1, - focusPath: [1, 1, 1], + focusPath: [1, 2, 1], }); }, ); @@ -960,7 +1052,7 @@ test.describe.parallel('Selection', () => { anchorOffset: 0, anchorPath: [1], focusOffset: 1, - focusPath: [0, 0, 0], + focusPath: [0, 1, 0], }); }, ); diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 959d3395395..75bda0f018b 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -90,6 +90,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +


@@ -132,6 +136,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +

abc

@@ -171,9 +179,9 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); await moveLeft(page, 1); @@ -188,17 +196,17 @@ test.describe.parallel('Tables', () => { await page.keyboard.type('ab'); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 0, 0, 0, 0, 0], + anchorPath: [1, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 0, 0, 0, 0, 0], + focusPath: [1, 1, 0, 0, 0, 0], }); await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, 2, 1, 0], }); }); @@ -216,9 +224,9 @@ test.describe.parallel('Tables', () => { await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, 2, 1, 0], }); await moveRight(page, 1); @@ -233,9 +241,9 @@ test.describe.parallel('Tables', () => { await page.keyboard.type('ab'); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 1, 1, 0, 0, 0], + anchorPath: [1, 2, 1, 0, 0, 0], focusOffset: 2, - focusPath: [1, 1, 1, 0, 0, 0], + focusPath: [1, 2, 1, 0, 0, 0], }); await moveRight(page, 3); @@ -261,17 +269,17 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 1, 0, 0, 0], + anchorPath: [1, 1, 0, 1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 1, 0, 0, 0], + focusPath: [1, 1, 0, 1, 1, 0, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); }); @@ -290,17 +298,17 @@ test.describe.parallel('Tables', () => { await moveRight(page, 3); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 1, 1, 1, 0], + anchorPath: [1, 1, 0, 1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 0, 0, 1, 1, 1, 0], + focusPath: [1, 1, 0, 1, 2, 1, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 2], + anchorPath: [1, 1, 0, 2], focusOffset: 0, - focusPath: [1, 0, 0, 2], + focusPath: [1, 1, 0, 2], }); }); }); @@ -335,15 +343,19 @@ test.describe.parallel('Tables', () => { await deleteBackward(page); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, 2, 1, 0], }); await assertHTML( page, html`


+ + + +


@@ -389,6 +401,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +


@@ -446,6 +462,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +


@@ -492,9 +512,9 @@ test.describe.parallel('Tables', () => { await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, 2, 1, 0], }); }); @@ -515,6 +535,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +


@@ -540,57 +564,57 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 1, 0], + anchorPath: [1, 1, 1, 0], focusOffset: 0, - focusPath: [1, 0, 1, 0], + focusPath: [1, 1, 1, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, 2, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, 2, 0, 0], }); await moveRight(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 1, 0], + anchorPath: [1, 2, 1, 0], focusOffset: 0, - focusPath: [1, 1, 1, 0], + focusPath: [1, 2, 1, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, 2, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, 2, 0, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 1, 0], + anchorPath: [1, 1, 1, 0], focusOffset: 0, - focusPath: [1, 0, 1, 0], + focusPath: [1, 1, 1, 0], }); await moveLeft(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); }); @@ -607,25 +631,25 @@ test.describe.parallel('Tables', () => { await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); await moveDown(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 1, 0, 0], + anchorPath: [1, 2, 0, 0], focusOffset: 0, - focusPath: [1, 1, 0, 0], + focusPath: [1, 2, 0, 0], }); await moveUp(page, 1); await assertSelection(page, { anchorOffset: 0, - anchorPath: [1, 0, 0, 0], + anchorPath: [1, 1, 0, 0], focusOffset: 0, - focusPath: [1, 0, 0, 0], + focusPath: [1, 1, 0, 0], }); }); @@ -643,9 +667,9 @@ test.describe.parallel('Tables', () => { await page.keyboard.type('@A'); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 0, 0, 0, 0, 0], + anchorPath: [1, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 0, 0, 0, 0, 0], + focusPath: [1, 1, 0, 0, 0, 0], }); await waitForSelector(page, `#typeahead-menu ul li:first-child.selected`); @@ -653,9 +677,9 @@ test.describe.parallel('Tables', () => { await moveDown(page, 1); await assertSelection(page, { anchorOffset: 2, - anchorPath: [1, 0, 0, 0, 0, 0], + anchorPath: [1, 1, 0, 0, 0, 0], focusOffset: 2, - focusPath: [1, 0, 0, 0, 0, 0], + focusPath: [1, 1, 0, 0, 0, 0], }); await waitForSelector( @@ -691,6 +715,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -723,6 +752,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +

a

@@ -775,7 +809,7 @@ test.describe.parallel('Tables', () => { } const firstRowFirstColumnCellBoundingBox = await p.locator( - 'table:first-of-type > tr:nth-child(1) > th:nth-child(1)', + 'table:first-of-type > :nth-match(tr, 1) > th:nth-child(1)', ); // Focus on inside the iFrame or the boundingBox() below returns null. @@ -795,6 +829,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -838,6 +877,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +

a

@@ -909,6 +953,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -941,6 +990,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +

a

@@ -1006,6 +1060,10 @@ test.describe.parallel('Tables', () => { page, html` + + + +

a

@@ -1024,6 +1082,11 @@ test.describe.parallel('Tables', () => {
+ + + + +

a

@@ -1084,6 +1147,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +


@@ -1136,6 +1204,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -1152,6 +1224,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +


@@ -1187,6 +1263,11 @@ test.describe.parallel('Tables', () => { html`

Hello World

+ + + + +
@@ -1221,6 +1302,11 @@ test.describe.parallel('Tables', () => { html`

Hello World

+ + + + +


@@ -1288,6 +1374,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +

123

@@ -1306,86 +1396,90 @@ test.describe.parallel('Tables', () => { ); }); - test('Grid selection: can select multiple cells and insert an image', async ({ - page, - isPlainText, - isCollab, - browserName, - }) => { - await initialize({isCollab, page}); - test.skip(isPlainText); + test( + 'Grid selection: can select multiple cells and insert an image', + { + tag: '@flaky', + }, + async ({page, isPlainText, isCollab, browserName}) => { + await initialize({isCollab, page}); + test.skip(isPlainText); - await focusEditor(page); + await focusEditor(page); - await insertTable(page, 2, 2); + await insertTable(page, 2, 2); - await click(page, '.PlaygroundEditorTheme__tableCell'); - await page.keyboard.type('Hello'); + await click(page, '.PlaygroundEditorTheme__tableCell:first-child'); + await page.keyboard.type('Hello'); - await page.keyboard.down('Shift'); - await page.keyboard.press('ArrowRight'); - // Firefox range selection spans across cells after two arrow key press - if (browserName === 'firefox') { + await page.keyboard.down('Shift'); await page.keyboard.press('ArrowRight'); - } - await page.keyboard.press('ArrowDown'); - await page.keyboard.up('Shift'); + // Firefox range selection spans across cells after two arrow key press + if (browserName === 'firefox') { + await page.keyboard.press('ArrowRight'); + } + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); - await insertSampleImage(page); - await page.keyboard.type(' <- it works!'); + await insertSampleImage(page); + await page.keyboard.type(' <- it works!'); - // Wait for Decorator to mount. - await page.waitForTimeout(3000); + await waitForSelector(page, '.editor-image img'); - await assertHTML( - page, - html` -


- - - - - - - - - -
-

- Hello -

-
-


-
-


-
-

- -

- Yellow flower in tilt shift lens -
- - <- it works! -

-
-


- `, - ); - }); + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + +
+

+ Hello +

+
+


+
+


+
+

+ +

+ Yellow flower in tilt shift lens +
+ + <- it works! +

+
+


+ `, + ); + }, + ); test('Grid selection: can backspace lines, backspacing empty cell does not destroy it #3278', async ({ page, @@ -1411,6 +1505,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -1445,6 +1543,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -1498,6 +1600,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -1535,6 +1641,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -1608,6 +1718,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + + - @@ -1634,7 +1748,7 @@ test.describe.parallel('Tables', () => { - @@ -1686,6 +1800,11 @@ test.describe.parallel('Tables', () => { html`


{


+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader">


+



+


+ + + + +
{
+ class="PlaygroundEditorTheme__tableCell PlaygroundEditorTheme__tableCellHeader">


@@ -1766,7 +1884,12 @@ test.describe.parallel('Tables', () => { html`


- + + + + + + - + @@ -1842,6 +1965,11 @@ test.describe.parallel('Tables', () => { html`


{



+ + + + +
@@ -1873,6 +2001,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -1932,6 +2065,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -1983,6 +2121,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -2069,6 +2212,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -2126,267 +2274,291 @@ test.describe.parallel('Tables', () => { ); }); - test('Select multiple merged cells (selection expands to a rectangle)', async ({ - page, - isPlainText, - isCollab, - }) => { - await initialize({isCollab, page}); - test.skip(isPlainText); + test( + 'Select multiple merged cells (selection expands to a rectangle)', + { + tag: '@flaky', + }, + async ({page, isPlainText, isCollab}) => { + await initialize({isCollab, page}); + test.skip(isPlainText); - await focusEditor(page); + await focusEditor(page); - await insertTable(page, 3, 3); + await insertTable(page, 3, 3); - await click(page, '.PlaygroundEditorTheme__tableCell'); - await moveDown(page, 1); - await selectCellsFromTableCords( - page, - {x: 0, y: 0}, - {x: 0, y: 1}, - true, - true, - ); - await mergeTableCells(page); + await click(page, '.PlaygroundEditorTheme__tableCell'); + await moveDown(page, 1); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 0, y: 1}, + true, + true, + ); + await mergeTableCells(page); - await moveRight(page, 1); - await selectCellsFromTableCords( - page, - {x: 1, y: 0}, - {x: 2, y: 0}, - true, - true, - ); - await mergeTableCells(page); + await moveRight(page, 1); + await selectCellsFromTableCords( + page, + {x: 1, y: 0}, + {x: 2, y: 0}, + true, + true, + ); + await mergeTableCells(page); - await selectCellsFromTableCords( - page, - {x: 0, y: 0}, - {x: 1, y: 0}, - true, - true, - ); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 0}, + true, + true, + ); - await assertHTML( - page, - html` -


- - - - - - - - - - - - - - -
-


-
-


-
-


-
-


-
-


-
-


-
-


-
-


- `, - html` -


- - - - - - - - - - - - - - -
-


-
-


-
-


-
-


-
-


-
-


-
-


-
-


- `, - ); - }); + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + html` +


+ + + + + + + + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }, + ); - test('Merge multiple merged cells and then unmerge', async ({ - page, - isPlainText, - isCollab, - }) => { - await initialize({isCollab, page}); - test.skip(isPlainText); + test( + 'Merge multiple merged cells and then unmerge', + { + tag: '@flaky', + }, + async ({page, isPlainText, isCollab}) => { + await initialize({isCollab, page}); + test.skip(isPlainText); - await focusEditor(page); + await focusEditor(page); - await insertTable(page, 3, 3); + await insertTable(page, 3, 3); - await click(page, '.PlaygroundEditorTheme__tableCell'); - await moveDown(page, 1); - await selectCellsFromTableCords( - page, - {x: 0, y: 0}, - {x: 0, y: 1}, - true, - true, - ); - await mergeTableCells(page); + await click(page, '.PlaygroundEditorTheme__tableCell'); + await moveDown(page, 1); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 0, y: 1}, + true, + true, + ); + await mergeTableCells(page); - await moveRight(page, 1); - await selectCellsFromTableCords( - page, - {x: 1, y: 0}, - {x: 2, y: 0}, - true, - true, - ); - await mergeTableCells(page); + await moveRight(page, 1); + await selectCellsFromTableCords( + page, + {x: 1, y: 0}, + {x: 2, y: 0}, + true, + true, + ); + await mergeTableCells(page); - await selectCellsFromTableCords( - page, - {x: 0, y: 0}, - {x: 1, y: 0}, - true, - true, - ); - await mergeTableCells(page); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 0}, + true, + true, + ); + await mergeTableCells(page); - await assertHTML( - page, - html` -


- - - - -
- - - - - -
-


-
-


-
-


-
-


-
-


- `, - ); + await assertHTML( + page, + html` +


+ + + + + + + + + +
+ + + + + +
+


+
+


+
+


+
+


+
+


+ `, + ); - await selectCellsFromTableCords( - page, - {x: 0, y: 0}, - {x: 0, y: 0}, - true, - true, - ); - await unmergeTableCell(page); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 0, y: 0}, + true, + true, + ); + await unmergeTableCell(page); - await assertHTML( - page, - html` -


- - - - - - - - - - - - - - - - -
-


-
-


-
-


-
-


-
-


-
-


-
-


-
-


-
-


-
-


- `, - ); - }); + await assertHTML( + page, + html` +


+ + + + + + + + + + + + + + + + + + + + + +
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }, + ); test('Insert row above (with conflicting merged cell)', async ({ page, @@ -2422,6 +2594,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -2486,6 +2662,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
{ html`


+ + + +
@@ -2597,6 +2782,12 @@ test.describe.parallel('Tables', () => { html`


+ + + + + +
@@ -2677,6 +2868,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -2739,6 +2934,10 @@ test.describe.parallel('Tables', () => { html`


+ + + +
@@ -2760,50 +2959,59 @@ test.describe.parallel('Tables', () => { ); }); - test('Delete columns backward', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); - test.skip(isPlainText); - if (IS_COLLAB) { - // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) - page.setViewportSize({height: 1000, width: 3000}); - } + test( + 'Delete columns backward', + { + tag: '@flaky', + }, + async ({page, isPlainText, isCollab}) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + if (IS_COLLAB) { + // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) + page.setViewportSize({height: 1000, width: 3000}); + } - await focusEditor(page); + await focusEditor(page); - await insertTable(page, 2, 4); + await insertTable(page, 2, 4); - await selectCellsFromTableCords( - page, - {x: 3, y: 1}, - {x: 1, y: 1}, - false, - false, - ); + await selectCellsFromTableCords( + page, + {x: 3, y: 1}, + {x: 1, y: 1}, + false, + false, + ); - await deleteTableColumns(page); + await deleteTableColumns(page); - await assertHTML( - page, - html` -


- - - - - - - -
-


-
-


-
-


- `, - ); - }); + await assertHTML( + page, + html` +


+ + + + + + + + + + +
+


+
+


+
+


+ `, + ); + }, + ); test('Delete columns forward at end of table', async ({ page, @@ -2836,6 +3044,9 @@ test.describe.parallel('Tables', () => { html`


+ + +
@@ -2902,6 +3113,9 @@ test.describe.parallel('Tables', () => { html`


+ + +
{ page, html` + + + + +

{ page, html` + + +

{ html`


+ + + + + +
@@ -3180,6 +3408,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -3220,6 +3453,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +

@@ -3287,6 +3525,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
@@ -3364,6 +3607,11 @@ test.describe.parallel('Tables', () => { html`


+ + + + +
diff --git a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs index eb039765480..12cec473d34 100644 --- a/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Toolbar.spec.mjs @@ -157,6 +157,13 @@ test.describe('Toolbar', () => {

+ + + + + + +


diff --git a/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs b/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs index 8a1988cc2d1..abcd0492817 100644 --- a/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs +++ b/packages/lexical-playground/__tests__/regression/4661-insert-column-selection.spec.mjs @@ -48,6 +48,12 @@ test.describe('Regression test #4661', () => { html`


+ + + + + +
@@ -86,6 +92,7 @@ test.describe('Regression test #4661', () => { `, ); }); + test('inserting 2 columns after inserts after selection', async ({ page, isPlainText, @@ -113,6 +120,12 @@ test.describe('Regression test #4661', () => { html`


+ + + + + + '; expect(testEnv.innerHTML).toBe( - `
diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index f2755a48583..907db4b110a 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -826,12 +826,12 @@ export async function selectCellsFromTableCords( } const firstRowFirstColumnCell = await leftFrame.locator( - `table:first-of-type > tr:nth-child(${firstCords.y + 1}) > ${ + `table:first-of-type > :nth-match(tr, ${firstCords.y + 1}) > ${ isFirstHeader ? 'th' : 'td' }:nth-child(${firstCords.x + 1})`, ); const secondRowSecondCell = await leftFrame.locator( - `table:first-of-type > tr:nth-child(${secondCords.y + 1}) > ${ + `table:first-of-type > :nth-match(tr, ${secondCords.y + 1}) > ${ isSecondHeader ? 'th' : 'td' }:nth-child(${secondCords.x + 1})`, ); @@ -961,3 +961,36 @@ export async function dragDraggableMenuTo( export async function pressInsertLinkButton(page) { await click(page, '.toolbar-item[aria-label="Insert link"]'); } + +/** + * Creates a selection object to assert against that is human readable and self-describing. + * + * Selections are composed of an anchorPath (the start) and a focusPath (the end). + * Once you traverse each path, you use the respective offsets to find the exact location of the cursor. + * So offsets are relative to their path. For example, if the anchorPath is [0, 1, 2] and the anchorOffset is 3, + * then the cursor is at the 4th character of the 3rd element of the 2nd element of the 1st element. + * + * @example + * const expectedSelection = createHumanReadableSelection('the full text of the last cell', { + * anchorOffset: {desc: 'beginning of cell', value: 0}, + * anchorPath: [ + * {desc: 'index of table in root', value: 1}, + * {desc: 'first table row', value: 0}, + * {desc: 'first cell', value: 0}, + * ], + * focusOffset: {desc: 'full text length', value: 9}, + * focusPath: [ + * {desc: 'index of last paragraph', value: 2}, + * {desc: 'index of first span', value: 0}, + * {desc: 'index of text block', value: 0}, + * ], + * }); + */ +export function createHumanReadableSelection(_overview, dto) { + return { + anchorOffset: dto.anchorOffset.value, + anchorPath: dto.anchorPath.map((p) => p.value), + focusOffset: dto.focusOffset.value, + focusPath: dto.focusPath.map((p) => p.value), + }; +} diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx index 903fe0f1e71..fcf2186ee19 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx @@ -15,8 +15,6 @@ import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, - $getSelection, - $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, @@ -41,7 +39,7 @@ export default function ExcalidrawComponent({ const [isModalOpen, setModalOpen] = useState( data === '[]' && editor.isEditable(), ); - const imageContainerRef = useRef(null); + const imageContainerRef = useRef(null); const buttonRef = useRef(null); const captionButtonRef = useRef(null); const [isSelected, setSelected, clearSelection] = @@ -50,23 +48,21 @@ export default function ExcalidrawComponent({ const $onDelete = useCallback( (event: KeyboardEvent) => { - const deleteSelection = $getSelection(); - if (isSelected && $isNodeSelection(deleteSelection)) { + if (isSelected) { event.preventDefault(); editor.update(() => { - deleteSelection.getNodes().forEach((node) => { - if ($isExcalidrawNode(node)) { - node.remove(); - } - }); + const node = $getNodeByKey(nodeKey); + if (node) { + node.remove(); + } }); } return false; }, - [editor, isSelected], + [editor, isSelected, nodeKey], ); - // Set editor to readOnly if excalidraw is open to prevent unwanted changes + // Set editor to readOnly if Excalidraw is open to prevent unwanted changes useEffect(() => { if (isModalOpen) { editor.setEditable(false); @@ -119,7 +115,7 @@ export default function ExcalidrawComponent({ setModalOpen(false); return editor.update(() => { const node = $getNodeByKey(nodeKey); - if ($isExcalidrawNode(node)) { + if (node) { node.remove(); } }); @@ -184,6 +180,21 @@ export default function ExcalidrawComponent({ appState = {}, } = useMemo(() => JSON.parse(data), [data]); + const [initialWidth, initialHeight] = useMemo(() => { + let nodeWidth: 'inherit' | number = 'inherit'; + let nodeHeight: 'inherit' | number = 'inherit'; + + editor.getEditorState().read(() => { + const node = $getNodeByKey(nodeKey); + if ($isExcalidrawNode(node)) { + nodeWidth = node.getWidth(); + nodeHeight = node.getHeight(); + } + }); + + return [nodeWidth, nodeHeight]; + }, [editor, nodeKey]); + return ( <> {isSelected && (
[]; /** - * The Excalidraw elements to be rendered as an image + * The Excalidraw files associated with the elements */ files: BinaryFiles; /** * The height of the image to be rendered */ - height?: number | null; + height?: Dimension; /** * The ref object to be used to render the image */ - imageContainerRef: {current: null | HTMLDivElement}; + imageContainerRef: React.MutableRefObject; /** * The type of image to be rendered */ @@ -53,7 +55,7 @@ type Props = { /** * The width of the image to be rendered */ - width?: number | null; + width?: Dimension; }; // exportToSvg has fonts from excalidraw.com @@ -85,6 +87,8 @@ export default function ExcalidrawImage({ imageContainerRef, appState, rootClassName = null, + width = 'inherit', + height = 'inherit', }: Props): JSX.Element { const [Svg, setSvg] = useState(null); @@ -106,10 +110,25 @@ export default function ExcalidrawImage({ setContent(); }, [elements, files, appState]); + const containerStyle: React.CSSProperties = {}; + if (width !== 'inherit') { + containerStyle.width = `${width}px`; + } + if (height !== 'inherit') { + containerStyle.height = `${height}px`; + } + return (
{ + if (node) { + if (imageContainerRef) { + imageContainerRef.current = node; + } + } + }} className={rootClassName ?? ''} + style={containerStyle} dangerouslySetInnerHTML={{__html: Svg?.outerHTML ?? ''}} /> ); diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx index 693a7418cc1..8b4c5796024 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/index.tsx @@ -29,8 +29,8 @@ const ExcalidrawComponent = React.lazy(() => import('./ExcalidrawComponent')); export type SerializedExcalidrawNode = Spread< { data: string; - width: Dimension; - height: Dimension; + width?: Dimension; + height?: Dimension; }, SerializedLexicalNode >; @@ -48,10 +48,7 @@ function $convertExcalidrawElement( !widthStr || widthStr === 'inherit' ? 'inherit' : parseInt(widthStr, 10); if (excalidrawData) { - const node = $createExcalidrawNode(); - node.__data = excalidrawData; - node.__height = height; - node.__width = width; + const node = $createExcalidrawNode(excalidrawData, width, height); return { node, }; @@ -80,18 +77,19 @@ export class ExcalidrawNode extends DecoratorNode { static importJSON(serializedNode: SerializedExcalidrawNode): ExcalidrawNode { return new ExcalidrawNode( serializedNode.data, - serializedNode.width, - serializedNode.height, + serializedNode.width ?? 'inherit', + serializedNode.height ?? 'inherit', ); } exportJSON(): SerializedExcalidrawNode { return { + ...super.exportJSON(), data: this.__data, - height: this.__height, + height: this.__height === 'inherit' ? undefined : this.__height, type: 'excalidraw', version: 1, - width: this.__width, + width: this.__width === 'inherit' ? undefined : this.__width, }; } @@ -113,6 +111,8 @@ export class ExcalidrawNode extends DecoratorNode { const theme = config.theme; const className = theme.image; + span.style.display = 'inline-block'; + span.style.width = this.__width === 'inherit' ? 'inherit' : `${this.__width}px`; span.style.height = @@ -124,7 +124,11 @@ export class ExcalidrawNode extends DecoratorNode { return span; } - updateDOM(): false { + updateDOM(prevNode: ExcalidrawNode, dom: HTMLElement): boolean { + dom.style.width = + this.__width === 'inherit' ? 'inherit' : `${this.__width}px`; + dom.style.height = + this.__height === 'inherit' ? 'inherit' : `${this.__height}px`; return false; } @@ -169,11 +173,19 @@ export class ExcalidrawNode extends DecoratorNode { self.__data = data; } + getWidth(): Dimension { + return this.getLatest().__width; + } + setWidth(width: Dimension): void { const self = this.getWritable(); self.__width = width; } + getHeight(): Dimension { + return this.getLatest().__height; + } + setHeight(height: Dimension): void { const self = this.getWritable(); self.__height = height; @@ -188,12 +200,16 @@ export class ExcalidrawNode extends DecoratorNode { } } -export function $createExcalidrawNode(): ExcalidrawNode { - return new ExcalidrawNode(); +export function $createExcalidrawNode( + data: string = '[]', + width: Dimension = 'inherit', + height: Dimension = 'inherit', +): ExcalidrawNode { + return new ExcalidrawNode(data, width, height); } export function $isExcalidrawNode( - node: LexicalNode | null, + node: LexicalNode | null | undefined, ): node is ExcalidrawNode { return node instanceof ExcalidrawNode; } diff --git a/packages/lexical-playground/src/nodes/PollNode.css b/packages/lexical-playground/src/nodes/PollNode.css index bfa30817bd0..2b41d80cdb6 100644 --- a/packages/lexical-playground/src/nodes/PollNode.css +++ b/packages/lexical-playground/src/nodes/PollNode.css @@ -105,6 +105,7 @@ margin: 0; transform: rotate(45deg); border-width: 0 2px 2px 0; + pointer-events: none; } .PollNode__optionCheckbox { border: 0px; diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx index 0eb04da82ae..f0446ee6b1f 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx +++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx @@ -5,12 +5,7 @@ * LICENSE file in the root directory of this source tree. * */ -import type { - TableCellNode, - TableDOMCell, - TableMapType, - TableMapValueType, -} from '@lexical/table'; +import type {TableCellNode, TableDOMCell, TableMapType} from '@lexical/table'; import type {LexicalEditor} from 'lexical'; import './index.css'; @@ -24,6 +19,7 @@ import { $isTableCellNode, $isTableRowNode, getDOMCellFromTarget, + TableNode, } from '@lexical/table'; import {calculateZoomLevel} from '@lexical/utils'; import {$getNearestNodeFromDOMNode} from 'lexical'; @@ -47,7 +43,7 @@ type MousePosition = { type MouseDraggingDirection = 'right' | 'bottom'; const MIN_ROW_HEIGHT = 33; -const MIN_COLUMN_WIDTH = 50; +const MIN_COLUMN_WIDTH = 92; function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { const targetRef = useRef(null); @@ -75,6 +71,20 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { return (event.buttons & 1) === 1; }; + useEffect(() => { + return editor.registerNodeTransform(TableNode, (tableNode) => { + if (tableNode.getColWidths()) { + return tableNode; + } + + const numColumns = tableNode.getColumnCount(); + const columnWidth = MIN_COLUMN_WIDTH; + + tableNode.setColWidths(Array(numColumns).fill(columnWidth)); + return tableNode; + }); + }, [editor]); + useEffect(() => { const onMouseMove = (event: MouseEvent) => { setTimeout(() => { @@ -173,7 +183,9 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode); const tableRowIndex = - $getTableRowIndexFromTableCellNode(tableCellNode); + $getTableRowIndexFromTableCellNode(tableCellNode) + + tableCellNode.getRowSpan() - + 1; const tableRows = tableNode.getChildren(); @@ -206,27 +218,6 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { [activeCell, editor], ); - const getCellNodeWidth = ( - cell: TableCellNode, - activeEditor: LexicalEditor, - ): number | undefined => { - const width = cell.getWidth(); - if (width !== undefined) { - return width; - } - - const domCellNode = activeEditor.getElementByKey(cell.getKey()); - if (domCellNode == null) { - return undefined; - } - const computedStyle = getComputedStyle(domCellNode); - return ( - domCellNode.clientWidth - - parseFloat(computedStyle.paddingLeft) - - parseFloat(computedStyle.paddingRight) - ); - }; - const getCellNodeHeight = ( cell: TableCellNode, activeEditor: LexicalEditor, @@ -271,22 +262,18 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { throw new Error('TableCellResizer: Table column not found.'); } - for (let row = 0; row < tableMap.length; row++) { - const cell: TableMapValueType = tableMap[row][columnIndex]; - if ( - cell.startRow === row && - (columnIndex === tableMap[row].length - 1 || - tableMap[row][columnIndex].cell !== - tableMap[row][columnIndex + 1].cell) - ) { - const width = getCellNodeWidth(cell.cell, editor); - if (width === undefined) { - continue; - } - const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH); - cell.cell.setWidth(newWidth); - } + const colWidths = tableNode.getColWidths(); + if (!colWidths) { + return; + } + const width = colWidths[columnIndex]; + if (width === undefined) { + return; } + const newColWidths = [...colWidths]; + const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH); + newColWidths[columnIndex] = newWidth; + tableNode.setColWidths(newColWidths); }, {tag: 'skip-scroll-into-view'}, ); diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index f79fb7d3a86..22d27e4145e 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -122,7 +122,7 @@ overflow-y: scroll; overflow-x: scroll; table-layout: fixed; - width: max-content; + width: fit-content; margin: 0px 25px 30px 0px; } .PlaygroundEditorTheme__tableRowStriping tr:nth-child(even) { @@ -137,7 +137,6 @@ .PlaygroundEditorTheme__tableCell { border: 1px solid #bbb; width: 75px; - min-width: 75px; vertical-align: top; text-align: start; padding: 6px 8px; diff --git a/packages/lexical-react/src/shared/useYjsCollaboration.tsx b/packages/lexical-react/src/shared/useYjsCollaboration.tsx index 7017e96fec3..a8f4e49da17 100644 --- a/packages/lexical-react/src/shared/useYjsCollaboration.tsx +++ b/packages/lexical-react/src/shared/useYjsCollaboration.tsx @@ -57,9 +57,7 @@ export function useYjsCollaboration( ): JSX.Element { const isReloadingDoc = useRef(false); - const connect = useCallback(() => { - provider.connect(); - }, [provider]); + const connect = useCallback(() => provider.connect(), [provider]); const disconnect = useCallback(() => { try { @@ -152,11 +150,23 @@ export function useYjsCollaboration( } }, ); - connect(); + + const connectionPromise = connect(); return () => { if (isReloadingDoc.current === false) { - disconnect(); + if (connectionPromise) { + connectionPromise.then(disconnect); + } else { + // Workaround for race condition in StrictMode. It's possible there + // is a different race for the above case where connect returns a + // promise, but we don't have an example of that in-repo. + // It's possible that there is a similar issue with + // TOGGLE_CONNECT_COMMAND below when the provider connect returns a + // promise. + // https://github.com/facebook/lexical/issues/6640 + disconnect(); + } } provider.off('sync', onSync); @@ -197,18 +207,16 @@ export function useYjsCollaboration( return editor.registerCommand( TOGGLE_CONNECT_COMMAND, (payload) => { - if (connect !== undefined && disconnect !== undefined) { - const shouldConnect = payload; - - if (shouldConnect) { - // eslint-disable-next-line no-console - console.log('Collaboration connected!'); - connect(); - } else { - // eslint-disable-next-line no-console - console.log('Collaboration disconnected!'); - disconnect(); - } + const shouldConnect = payload; + + if (shouldConnect) { + // eslint-disable-next-line no-console + console.log('Collaboration connected!'); + connect(); + } else { + // eslint-disable-next-line no-console + console.log('Collaboration disconnected!'); + disconnect(); } return true; diff --git a/packages/lexical-selection/flow/LexicalSelection.js.flow b/packages/lexical-selection/flow/LexicalSelection.js.flow index 7802dc55721..d1d68d66376 100644 --- a/packages/lexical-selection/flow/LexicalSelection.js.flow +++ b/packages/lexical-selection/flow/LexicalSelection.js.flow @@ -20,6 +20,7 @@ declare export function $cloneWithProperties(node: T): T; declare export function getStyleObjectFromCSS(css: string): { [string]: string, }; +declare export function getCSSFromStyleObject(styles: Record): string; declare export function $patchStyleText( selection: BaseSelection, patch: { diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts index b2d18b1645a..d901ab4d4d9 100644 --- a/packages/lexical-selection/src/index.ts +++ b/packages/lexical-selection/src/index.ts @@ -26,6 +26,7 @@ import { import { createDOMRange, createRectsFromDOMRange, + getCSSFromStyleObject, getStyleObjectFromCSS, } from './utils'; @@ -53,4 +54,9 @@ export { $wrapNodes, }; -export {createDOMRange, createRectsFromDOMRange, getStyleObjectFromCSS}; +export { + createDOMRange, + createRectsFromDOMRange, + getCSSFromStyleObject, + getStyleObjectFromCSS, +}; diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 938d8c61c63..90031636d26 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -31,16 +31,39 @@ import { import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; -import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; +import {TableRowNode} from './LexicalTableRowNode'; import {getTable} from './LexicalTableSelectionHelpers'; export type SerializedTableNode = Spread< { + colWidths?: readonly number[]; rowStriping?: boolean; }, SerializedElementNode >; +function updateColgroup( + dom: HTMLElement, + config: EditorConfig, + colCount: number, + colWidths?: number[] | readonly number[], +) { + const colGroup = dom.querySelector('colgroup'); + if (!colGroup) { + return; + } + const cols = []; + for (let i = 0; i < colCount; i++) { + const col = document.createElement('col'); + const width = colWidths && colWidths[i]; + if (width) { + col.style.width = `${width}px`; + } + cols.push(col); + } + colGroup.replaceChildren(...cols); +} + function setRowStriping( dom: HTMLElement, config: EditorConfig, @@ -59,17 +82,31 @@ function setRowStriping( export class TableNode extends ElementNode { /** @internal */ __rowStriping: boolean; + __colWidths?: number[] | readonly number[]; static getType(): string { return 'table'; } + getColWidths(): number[] | readonly number[] | undefined { + const self = this.getLatest(); + return self.__colWidths; + } + + setColWidths(colWidths: readonly number[]): this { + const self = this.getWritable(); + // NOTE: Node properties should be immutable. Freeze to prevent accidental mutation. + self.__colWidths = __DEV__ ? Object.freeze(colWidths) : colWidths; + return self; + } + static clone(node: TableNode): TableNode { return new TableNode(node.__key); } afterCloneFrom(prevNode: this) { super.afterCloneFrom(prevNode); + this.__colWidths = prevNode.__colWidths; this.__rowStriping = prevNode.__rowStriping; } @@ -85,6 +122,7 @@ export class TableNode extends ElementNode { static importJSON(serializedNode: SerializedTableNode): TableNode { const tableNode = $createTableNode(); tableNode.__rowStriping = serializedNode.rowStriping || false; + tableNode.__colWidths = serializedNode.colWidths; return tableNode; } @@ -96,6 +134,7 @@ export class TableNode extends ElementNode { exportJSON(): SerializedTableNode { return { ...super.exportJSON(), + colWidths: this.getColWidths(), rowStriping: this.__rowStriping ? this.__rowStriping : undefined, type: 'table', version: 1, @@ -104,6 +143,14 @@ export class TableNode extends ElementNode { createDOM(config: EditorConfig, editor?: LexicalEditor): HTMLElement { const tableElement = document.createElement('table'); + const colGroup = document.createElement('colgroup'); + tableElement.appendChild(colGroup); + updateColgroup( + tableElement, + config, + this.getColumnCount(), + this.getColWidths(), + ); addClassNamesToElement(tableElement, config.theme.table); if (this.__rowStriping) { @@ -121,6 +168,7 @@ export class TableNode extends ElementNode { if (prevNode.__rowStriping !== this.__rowStriping) { setRowStriping(dom, config, this.__rowStriping); } + updateColgroup(dom, config, this.getColumnCount(), this.getColWidths()); return false; } @@ -133,19 +181,10 @@ export class TableNode extends ElementNode { const colGroup = document.createElement('colgroup'); const tBody = document.createElement('tbody'); if (isHTMLElement(tableElement)) { - tBody.append(...tableElement.children); - } - const firstRow = this.getFirstChildOrThrow(); - - if (!$isTableRowNode(firstRow)) { - throw new Error('Expected to find row node.'); - } - - const colCount = firstRow.getChildrenSize(); - - for (let i = 0; i < colCount; i++) { - const col = document.createElement('col'); - colGroup.append(col); + const cols = tableElement.querySelectorAll('col'); + colGroup.append(...cols); + const rows = tableElement.querySelectorAll('tr'); + tBody.append(...rows); } newElement.replaceChildren(colGroup, tBody); @@ -281,6 +320,22 @@ export class TableNode extends ElementNode { canIndent(): false { return false; } + + getColumnCount(): number { + const firstRow = this.getFirstChild(); + if (!firstRow) { + return 0; + } + + let columnCount = 0; + firstRow.getChildren().forEach((cell) => { + if ($isTableCellNode(cell)) { + columnCount += cell.getColSpan(); + } + }); + + return columnCount; + } } export function $getElementForTableNode( diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index a1114107d43..03030509a52 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -21,12 +21,13 @@ import { $getRoot, $getSelection, $isElementNode, + $isParagraphNode, $setSelection, SELECTION_CHANGE_COMMAND, } from 'lexical'; import invariant from 'shared/invariant'; -import {$isTableCellNode} from './LexicalTableCellNode'; +import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; import {$isTableNode} from './LexicalTableNode'; import { $createTableSelection, @@ -356,12 +357,16 @@ export class TableObserver { const anchor = formatSelection.anchor; const focus = formatSelection.focus; - selection.getNodes().forEach((cellNode) => { - if ($isTableCellNode(cellNode) && cellNode.getTextContentSize() !== 0) { - anchor.set(cellNode.getKey(), 0, 'element'); - focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); - formatSelection.formatText(type); - } + const cellNodes = selection.getNodes().filter($isTableCellNode); + const paragraph = cellNodes[0].getFirstChild(); + const alignFormatWith = $isParagraphNode(paragraph) + ? paragraph.getFormatFlags(type, null) + : null; + + cellNodes.forEach((cellNode: TableCellNode) => { + anchor.set(cellNode.getKey(), 0, 'element'); + focus.set(cellNode.getKey(), cellNode.getChildrenSize(), 'element'); + formatSelection.formatText(type, alignFormatWith); }); $setSelection(selection); diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index bb03a8b2525..4353604567e 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -781,6 +781,25 @@ export function applyTableHandlers( : lastCell.getChildrenSize(), 'element', ); + } else if (isAnchorInside) { + const [tableMap] = $computeTableMap( + tableNode, + anchorCellNode, + anchorCellNode, + ); + const firstCell = tableMap[0][0].cell; + const lastCell = tableMap[tableMap.length - 1].at(-1)!.cell; + /** + * If isBackward, set the anchor to be at the end of the table so that when the cursor moves outside of + * the table in the backward direction, the entire table will be selected from its end. + * Otherwise, if forward, set the anchor to be at the start of the table so that when the focus is dragged + * outside th end of the table, it will start from the beginning of the table. + */ + newSelection.anchor.set( + isBackward ? lastCell.getKey() : firstCell.getKey(), + isBackward ? lastCell.getChildrenSize() : 0, + 'element', + ); } $setSelection(newSelection); $addHighlightStyleToTable(editor, tableObserver); @@ -995,7 +1014,7 @@ export function getTable(tableElement: HTMLElement): TableDOMTable { domRows, rows: 0, }; - let currentNode = tableElement.firstChild; + let currentNode = tableElement.querySelector('tr') as ChildNode | null; let x = 0; let y = 0; domRows.length = 0; diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts index d3e7d5ab888..c674c4a8b88 100644 --- a/packages/lexical-table/src/LexicalTableUtils.ts +++ b/packages/lexical-table/src/LexicalTableUtils.ts @@ -471,6 +471,14 @@ export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void { if (firstInsertedCell !== null) { $moveSelectionToCell(firstInsertedCell); } + const colWidths = grid.getColWidths(); + if (colWidths) { + const newColWidths = [...colWidths]; + const columnIndex = insertAfterColumn < 0 ? 0 : insertAfterColumn; + const newWidth = newColWidths[columnIndex]; + newColWidths.splice(columnIndex, 0, newWidth); + grid.setColWidths(newColWidths); + } } export function $deleteTableColumn( @@ -643,6 +651,12 @@ export function $deleteTableColumn__EXPERIMENTAL(): void { const {cell} = previousRow; $moveSelectionToCell(cell); } + const colWidths = grid.getColWidths(); + if (colWidths) { + const newColWidths = [...colWidths]; + newColWidths.splice(startColumn, selectedColumnCount); + grid.setColWidths(newColWidths); + } } function $moveSelectionToCell(cell: TableCellNode): void { diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 161def23622..fb1d9af2332 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -12,6 +12,7 @@ import { $createTableNode, $createTableNodeWithDimensions, $createTableSelection, + $insertTableColumn__EXPERIMENTAL, } from '@lexical/table'; import { $createParagraphNode, @@ -102,7 +103,7 @@ describe('LexicalTableNode tests', () => { const tableNode = $createTableNode(); expect(tableNode.createDOM(editorConfig).outerHTML).toBe( - `
`, + `
`, ); }); }); @@ -126,7 +127,7 @@ describe('LexicalTableNode tests', () => { // Make sure paragraph is inserted inside empty cells const emptyCell = '


${emptyCell}

Hello there

General Kenobi!

Lexical is nice

`, + `${emptyCell}

Hello there

General Kenobi!

Lexical is nice

`, ); }); @@ -147,7 +148,7 @@ describe('LexicalTableNode tests', () => { $insertDataTransferForRichText(dataTransfer, selection, editor); }); expect(testEnv.innerHTML).toBe( - `

Surface

MWP_WORK_LS_COMPOSER

77349

Lexical

XDS_RICH_TEXT_AREA

sdvd sdfvsfs

`, + `

Surface

MWP_WORK_LS_COMPOSER

77349

Lexical

XDS_RICH_TEXT_AREA

sdvd sdfvsfs

`, ); }); @@ -292,7 +293,7 @@ describe('LexicalTableNode tests', () => { }); expect(testEnv.innerHTML).toBe( - `


















`, + `


















`, ); }); @@ -366,7 +367,7 @@ describe('LexicalTableNode tests', () => { const root = $getRoot(); const table = root.getLastChild(); expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, + `
`, ); }); @@ -382,10 +383,69 @@ describe('LexicalTableNode tests', () => { const root = $getRoot(); const table = root.getLastChild(); expect(table!.createDOM(editorConfig).outerHTML).toBe( - `
`, + `
`, ); }); }); + + test('Update column widths', async () => { + const {editor} = testEnv; + + await editor.update(() => { + const root = $getRoot(); + const table = $createTableNodeWithDimensions(4, 2, true); + root.append(table); + }); + + // Set widths + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + table!.setColWidths([50, 50]); + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expect(table!.createDOM(editorConfig).outerHTML).toBe( + `
`, + ); + const colWidths = table!.getColWidths(); + + // colwidths should be immutable in DEV + expect(() => { + (colWidths as number[]).push(100); + }).toThrow(); + expect(table!.getColWidths()).toStrictEqual([50, 50]); + expect(table!.getColumnCount()).toBe(2); + }); + + // Add a column + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + const DOMTable = $getElementForTableNode(editor, table!); + const selection = $createTableSelection(); + selection.set( + table!.__key, + table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + table!.getCellNodeFromCords(0, 0, DOMTable)?.__key || '', + ); + $setSelection(selection); + $insertTableColumn__EXPERIMENTAL(); + table!.setColWidths([50, 50, 100]); + }); + + await editor.update(() => { + const root = $getRoot(); + const table = root.getLastChild(); + expect(table!.createDOM(editorConfig).outerHTML).toBe( + `
`, + ); + expect(table!.getColWidths()).toStrictEqual([50, 50, 100]); + expect(table!.getColumnCount()).toBe(3); + }); + }); }, undefined, , diff --git a/packages/lexical-website/docs/concepts/node-replacement.md b/packages/lexical-website/docs/concepts/node-replacement.md index 386a7e83b5d..8090b41ac05 100644 --- a/packages/lexical-website/docs/concepts/node-replacement.md +++ b/packages/lexical-website/docs/concepts/node-replacement.md @@ -16,7 +16,8 @@ const editorConfig = { replace: ParagraphNode, with: (node: ParagraphNode) => { return new CustomParagraphNode(); - } + }, + withKlass: CustomParagraphNode, } ] } diff --git a/packages/lexical-website/docs/concepts/serialization.md b/packages/lexical-website/docs/concepts/serialization.md index 64e7fe14193..03ed5f924e8 100644 --- a/packages/lexical-website/docs/concepts/serialization.md +++ b/packages/lexical-website/docs/concepts/serialization.md @@ -295,7 +295,11 @@ const initialConfig: InitialConfigType = { onError: (error: any) => console.log(error), nodes: [ ExtendedTextNode, - { replace: TextNode, with: (node: TextNode) => new ExtendedTextNode(node.__text) }, + { + replace: TextNode, + with: (node: TextNode) => new ExtendedTextNode(node.__text), + withKlass: ExtendedTextNode, + }, ListNode, ListItemNode, ] @@ -306,6 +310,7 @@ and create a new Extended Text Node plugin ```js import { + $applyNodeReplacement, $isTextNode, DOMConversion, DOMConversionMap, @@ -378,7 +383,7 @@ export class ExtendedTextNode extends TextNode { } export function $createExtendedTextNode(text: string): ExtendedTextNode { - return new ExtendedTextNode(text); + return $applyNodeReplacement(new ExtendedTextNode(text)); } export function $isExtendedTextNode(node: LexicalNode | null | undefined): node is ExtendedTextNode { diff --git a/packages/lexical-website/docs/intro.md b/packages/lexical-website/docs/intro.md index 825fc2e5d65..5b4d62966c4 100644 --- a/packages/lexical-website/docs/intro.md +++ b/packages/lexical-website/docs/intro.md @@ -81,7 +81,7 @@ Node Transforms and Command Listeners are called with an implicit `editor.update It is permitted to do nested updates, or nested reads, but an update should not be nested in a read or vice versa. For example, `editor.update(() => editor.update(() => {...}))` is allowed. It is permitted -to nest nest an `editor.read` at the end of an `editor.update`, but this will immediately flush the update +to nest an `editor.read` at the end of an `editor.update`, but this will immediately flush the update and any additional update in that callback will throw an error. All Lexical Nodes are dependent on the associated Editor State. With few exceptions, you should only call methods diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index 3a8937cedfa..33e5a952bd1 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -215,7 +215,7 @@ type EditorReadOptions = { }; type EditorUpdateOptions = { onUpdate?: () => void, - tag?: string, + tag?: string | Array, skipTransforms?: true, discrete?: true, }; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index e86e7b215e4..6016ae84956 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -80,7 +80,7 @@ export type TextNodeThemeClasses = { export type EditorUpdateOptions = { onUpdate?: () => void; skipTransforms?: true; - tag?: string; + tag?: string | Array; discrete?: true; }; diff --git a/packages/lexical/src/LexicalNode.ts b/packages/lexical/src/LexicalNode.ts index 5f70d3e36df..564989cdc2e 100644 --- a/packages/lexical/src/LexicalNode.ts +++ b/packages/lexical/src/LexicalNode.ts @@ -155,9 +155,9 @@ export type DOMExportOutputMap = Map< export type DOMExportOutput = { after?: ( - generatedElement: HTMLElement | Text | null | undefined, + generatedElement: HTMLElement | DocumentFragment | Text | null | undefined, ) => HTMLElement | Text | null | undefined; - element: HTMLElement | Text | null; + element: HTMLElement | DocumentFragment | Text | null; }; export type NodeKey = string; diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index e1486865c81..3a4e9ac0456 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -21,6 +21,7 @@ import { $isDecoratorNode, $isElementNode, $isLineBreakNode, + $isParagraphNode, $isRootNode, $isTextNode, $setSelection, @@ -1173,8 +1174,12 @@ export class RangeSelection implements BaseSelection { * merging nodes as necessary. * * @param formatType the format type to apply to the nodes in the Selection. + * @param alignWithFormat a 32-bit integer representing formatting flags to align with. */ - formatText(formatType: TextFormatType): void { + formatText( + formatType: TextFormatType, + alignWithFormat: number | null = null, + ): void { if (this.isCollapsed()) { this.toggleFormat(formatType); // When changing format, we should stop composition @@ -1222,7 +1227,16 @@ export class RangeSelection implements BaseSelection { return; } - const firstNextFormat = firstNode.getFormatFlags(formatType, null); + const firstNextFormat = firstNode.getFormatFlags( + formatType, + alignWithFormat, + ); + selectedNodes.forEach((node) => { + if ($isParagraphNode(node)) { + const newFormat = node.getFormatFlags(formatType, firstNextFormat); + node.setTextFormat(newFormat); + } + }); const lastIndex = selectedTextNodesLength - 1; let lastNode = selectedTextNodes[lastIndex]; diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 38e83cc56e8..11296a2be5a 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -219,6 +219,20 @@ function $normalizeAllDirtyTextNodes( } } +function addTags(editor: LexicalEditor, tags: undefined | string | string[]) { + if (!tags) { + return; + } + const updateTags = editor._updateTags; + let tags_ = tags; + if (!Array.isArray(tags)) { + tags_ = [tags]; + } + for (const tag of tags_) { + updateTags.add(tag); + } +} + /** * Transform heuristic: * 1. We transform leaves first. If transforms generate additional dirty nodes we repeat step 1. @@ -829,11 +843,9 @@ function processNestedUpdates( const [nextUpdateFn, options] = queuedUpdate; let onUpdate; - let tag; if (options !== undefined) { onUpdate = options.onUpdate; - tag = options.tag; if (options.skipTransforms) { skipTransforms = true; @@ -851,9 +863,7 @@ function processNestedUpdates( editor._deferred.push(onUpdate); } - if (tag) { - editor._updateTags.add(tag); - } + addTags(editor, options.tag); } nextUpdateFn(); @@ -870,17 +880,12 @@ function $beginUpdate( ): void { const updateTags = editor._updateTags; let onUpdate; - let tag; let skipTransforms = false; let discrete = false; if (options !== undefined) { onUpdate = options.onUpdate; - tag = options.tag; - - if (tag != null) { - updateTags.add(tag); - } + addTags(editor, options.tag); skipTransforms = options.skipTransforms || false; discrete = options.discrete || false; diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 7e3b2e49e1b..bf7904d3b90 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1405,30 +1405,53 @@ export function $copyNode(node: T): T { return copy; } -export function $applyNodeReplacement( - node: LexicalNode, -): N { +export function $applyNodeReplacement(node: N): N { const editor = getActiveEditor(); const nodeType = node.constructor.getType(); const registeredNode = editor._nodes.get(nodeType); - if (registeredNode === undefined) { - invariant( - false, - '$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.', - ); - } - const replaceFunc = registeredNode.replace; - if (replaceFunc !== null) { - const replacementNode = replaceFunc(node) as N; - if (!(replacementNode instanceof node.constructor)) { + invariant( + registeredNode !== undefined, + '$applyNodeReplacement node %s with type %s must be registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.', + node.constructor.name, + nodeType, + ); + const {replace, replaceWithKlass} = registeredNode; + if (replace !== null) { + const replacementNode = replace(node); + const replacementNodeKlass = replacementNode.constructor; + if (replaceWithKlass !== null) { invariant( - false, - '$initializeNode failed. Ensure replacement node is a subclass of the original node.', + replacementNode instanceof replaceWithKlass, + '$applyNodeReplacement failed. Expected replacement node to be an instance of %s with type %s but returned %s with type %s from original node %s with type %s', + replaceWithKlass.name, + replaceWithKlass.getType(), + replacementNodeKlass.name, + replacementNodeKlass.getType(), + node.constructor.name, + nodeType, + ); + } else { + invariant( + replacementNode instanceof node.constructor && + replacementNodeKlass !== node.constructor, + '$applyNodeReplacement failed. Ensure replacement node %s with type %s is a subclass of the original node %s with type %s.', + replacementNodeKlass.name, + replacementNodeKlass.getType(), + node.constructor.name, + nodeType, ); } - return replacementNode; + invariant( + replacementNode.__key !== node.__key, + '$applyNodeReplacement failed. Ensure that the key argument is *not* used in your replace function (from node %s with type %s to node %s with type %s), Node keys must never be re-used except by the static clone method.', + node.constructor.name, + nodeType, + replacementNodeKlass.name, + replacementNodeKlass.getType(), + ); + return replacementNode as N; } - return node as N; + return node; } export function errorOnInsertTextNodeOnRoot( @@ -1645,6 +1668,17 @@ export function isHTMLElement(x: Node | EventTarget): x is HTMLElement { return x.nodeType === 1; } +/** + * @param x - The element being testing + * @returns Returns true if x is a document fragment, false otherwise. + */ +export function isDocumentFragment( + x: Node | EventTarget, +): x is DocumentFragment { + // @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors + return x.nodeType === 11; +} + /** * * @param node - the Dom Node to check diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index b7714f03532..4b89e56c232 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -61,6 +61,7 @@ import {createRoot, Root} from 'react-dom/client'; import invariant from 'shared/invariant'; import * as ReactTestUtils from 'shared/react-test-utils'; +import {emptyFunction} from '../../LexicalUtils'; import { $createTestDecoratorNode, $createTestElementNode, @@ -2041,6 +2042,28 @@ describe('LexicalEditor tests', () => { ]); }); + it('multiple update tags', async () => { + init(); + const $mutateSomething = $createTextNode; + + editor.update($mutateSomething, { + tag: ['a', 'b'], + }); + expect(editor._updateTags).toEqual(new Set(['a', 'b'])); + editor.update( + () => { + editor.update(emptyFunction, {tag: ['e', 'f']}); + }, + { + tag: ['c', 'd'], + }, + ); + expect(editor._updateTags).toEqual(new Set(['a', 'b', 'c', 'd', 'e', 'f'])); + + await Promise.resolve(); + expect(editor._updateTags).toEqual(new Set([])); + }); + it('mutation listeners does not trigger when other node types are mutated', async () => { init(); diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts index 0026cf5d6ad..2a497860b07 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -7,24 +7,29 @@ */ import { + $applyNodeReplacement, + $createParagraphNode, + $createTextNode, $getNodeByKey, $getRoot, $isTokenOrSegmented, $nodesOfType, + createEditor, + isSelectionWithinEditor, + ParagraphNode, + resetRandomKey, + SerializedTextNode, + TextNode, +} from 'lexical'; + +import { emptyFunction, generateRandomKey, getCachedTypeToNodeMap, getTextDirection, isArray, - isSelectionWithinEditor, - resetRandomKey, scheduleMicroTask, } from '../../LexicalUtils'; -import { - $createParagraphNode, - ParagraphNode, -} from '../../nodes/LexicalParagraphNode'; -import {$createTextNode, TextNode} from '../../nodes/LexicalTextNode'; import {initializeUnitTest} from '../utils'; describe('LexicalUtils tests', () => { @@ -291,3 +296,283 @@ describe('LexicalUtils tests', () => { }); }); }); +describe('$applyNodeReplacement', () => { + class ExtendedTextNode extends TextNode { + static getType() { + return 'extended-text'; + } + static clone(node: ExtendedTextNode): ExtendedTextNode { + return new ExtendedTextNode(node.__text, node.getKey()); + } + exportJSON(): SerializedTextNode { + return {...super.exportJSON(), type: this.getType()}; + } + initWithTextNode(node: TextNode): this { + this.__text = node.__text; + TextNode.prototype.afterCloneFrom.call(this, node); + return this; + } + initWithJSON(serializedNode: SerializedTextNode): this { + this.setTextContent(serializedNode.text); + this.setFormat(serializedNode.format); + this.setDetail(serializedNode.detail); + this.setMode(serializedNode.mode); + this.setStyle(serializedNode.style); + return this; + } + static importJSON(serializedNode: SerializedTextNode): ExtendedTextNode { + return $createExtendedTextNode().initWithJSON(serializedNode); + } + } + class ExtendedExtendedTextNode extends ExtendedTextNode { + static getType() { + return 'extended-extended-text'; + } + static clone(node: ExtendedExtendedTextNode): ExtendedExtendedTextNode { + return new ExtendedExtendedTextNode(node.__text, node.getKey()); + } + initWithExtendedTextNode(node: ExtendedTextNode): this { + return this.initWithTextNode(node); + } + static importJSON( + serializedNode: SerializedTextNode, + ): ExtendedExtendedTextNode { + return $createExtendedExtendedTextNode().initWithJSON(serializedNode); + } + exportJSON(): SerializedTextNode { + return {...super.exportJSON(), type: this.getType()}; + } + } + function $createExtendedTextNode(text: string = '') { + return $applyNodeReplacement(new ExtendedTextNode(text)); + } + function $createExtendedExtendedTextNode(text: string = '') { + return $applyNodeReplacement(new ExtendedExtendedTextNode(text)); + } + test('validates replace node configuration', () => { + const editor = createEditor({ + nodes: [ + { + replace: TextNode, + with: (node) => $createExtendedTextNode().initWithTextNode(node), + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + 'Attempted to create node ExtendedTextNode that was not configured to be used on the editor', + ); + }); + test('validates replace node type withKlass', () => { + const editor = createEditor({ + nodes: [ + { + replace: TextNode, + with: (node) => node, + withKlass: ExtendedTextNode, + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + '$applyNodeReplacement failed. Expected replacement node to be an instance of ExtendedTextNode with type extended-text but returned TextNode with type text from original node TextNode with type text', + ); + }); + test('validates replace node type change', () => { + const editor = createEditor({ + nodes: [ + { + replace: TextNode, + with: (node: TextNode) => new TextNode(node.__text), + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + '$applyNodeReplacement failed. Ensure replacement node TextNode with type text is a subclass of the original node TextNode with type text', + ); + }); + test('validates replace node key change', () => { + const editor = createEditor({ + nodes: [ + { + replace: TextNode, + with: (node: TextNode) => + new ExtendedTextNode(node.__text, node.getKey()), + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + 'Lexical node with constructor ExtendedTextNode attempted to re-use key from node in active editor state with constructor TextNode. Keys must not be re-used when the type is changed.', + ); + }); + test('validates replace node configuration withKlass', () => { + const editor = createEditor({ + nodes: [ + { + replace: TextNode, + with: (node) => $createExtendedTextNode().initWithTextNode(node), + withKlass: ExtendedTextNode, + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + 'Attempted to create node ExtendedTextNode that was not configured to be used on the editor', + ); + }); + test('validates nested replace node configuration', () => { + const editor = createEditor({ + nodes: [ + ExtendedTextNode, + { + replace: ExtendedTextNode, + with: (node) => + $createExtendedExtendedTextNode().initWithExtendedTextNode(node), + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createExtendedTextNode('text')), + ); + }, + {discrete: true}, + ); + }).toThrow( + 'Attempted to create node ExtendedExtendedTextNode that was not configured to be used on the editor', + ); + }); + test('validates nested replace node configuration withKlass', () => { + const editor = createEditor({ + nodes: [ + ExtendedTextNode, + { + replace: TextNode, + with: (node) => $createExtendedTextNode().initWithTextNode(node), + withKlass: ExtendedTextNode, + }, + { + replace: ExtendedTextNode, + with: (node) => + $createExtendedExtendedTextNode().initWithExtendedTextNode(node), + withKlass: ExtendedExtendedTextNode, + }, + ], + onError(err) { + throw err; + }, + }); + expect(() => { + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + }).toThrow( + 'Attempted to create node ExtendedExtendedTextNode that was not configured to be used on the editor', + ); + }); + test('nested replace node configuration works', () => { + const editor = createEditor({ + nodes: [ + ExtendedTextNode, + ExtendedExtendedTextNode, + { + replace: TextNode, + with: (node) => $createExtendedTextNode().initWithTextNode(node), + withKlass: ExtendedTextNode, + }, + { + replace: ExtendedTextNode, + with: (node) => + $createExtendedExtendedTextNode().initWithExtendedTextNode(node), + withKlass: ExtendedExtendedTextNode, + }, + ], + onError(err) { + throw err; + }, + }); + editor.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('text'))); + }, + {discrete: true}, + ); + editor.read(() => { + const textNodes = $getRoot().getAllTextNodes(); + expect(textNodes).toHaveLength(1); + expect(textNodes[0].constructor).toBe(ExtendedExtendedTextNode); + expect(textNodes[0].getTextContent()).toBe('text'); + }); + }); +}); diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 5ef926b5afc..b3f5013cdd7 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -179,6 +179,7 @@ export { getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, isBlockDomNode, + isDocumentFragment, isHTMLAnchorElement, isHTMLElement, isInlineDomNode, diff --git a/packages/lexical/src/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index deab3a2cc13..ebbf9814581 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -30,6 +30,7 @@ import { $applyNodeReplacement, getCachedClassNameArray, isHTMLElement, + toggleTextFormatType, } from '../LexicalUtils'; import {ElementNode} from './LexicalElementNode'; import {$isTextNode, TextFormatType} from './LexicalTextNode'; @@ -75,6 +76,17 @@ export class ParagraphNode extends ElementNode { return (this.getTextFormat() & formatFlag) !== 0; } + /** + * Returns the format flags applied to the node as a 32-bit integer. + * + * @returns a number representing the TextFormatTypes applied to the node. + */ + getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number { + const self = this.getLatest(); + const format = self.__textFormat; + return toggleTextFormatType(format, type, alignWithFormat); + } + getTextStyle(): string { const self = this.getLatest(); return self.__textStyle;