diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5f5074869c1..431c342226a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,9 +9,7 @@ on: - 'packages/lexical-website/**' pull_request: types: [opened, synchronize, reopened] - paths-ignore: - - 'examples/**' - - 'packages/lexical-website/**' + merge_group: concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/examples/react-rich/src/App.tsx b/examples/react-rich/src/App.tsx index 8c16e827171..f71cc08adc7 100644 --- a/examples/react-rich/src/App.tsx +++ b/examples/react-rich/src/App.tsx @@ -17,6 +17,7 @@ import { DOMConversionMap, DOMExportOutput, DOMExportOutputMap, + isHTMLElement, Klass, LexicalEditor, LexicalNode, @@ -36,7 +37,7 @@ const removeStylesExportDOM = ( target: LexicalNode, ): DOMExportOutput => { const output = target.exportDOM(editor); - if (output && output.element instanceof HTMLElement) { + if (output && isHTMLElement(output.element)) { // Remove all inline styles and classes if the element is an HTMLElement // Children are checked as well since TextNode can be nested // in i, b, and strong tags. diff --git a/package-lock.json b/package-lock.json index e5387c0ffdc..4488fab50c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30584,6 +30584,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "dev": true, "bin": { "prettier": "bin-prettier.js" }, @@ -39153,7 +39154,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", @@ -39173,6 +39174,21 @@ "vite-plugin-static-copy": "^2.1.0" } }, + "packages/lexical-playground/node_modules/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "packages/lexical-react": { "name": "@lexical/react", "version": "0.21.0", @@ -56265,7 +56281,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", @@ -56275,6 +56291,13 @@ "vite-plugin-static-copy": "^2.1.0", "y-websocket": "^1.5.4", "yjs": ">=13.5.42" + }, + "dependencies": { + "prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==" + } } }, "lib0": { @@ -60208,7 +60231,8 @@ "prettier": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz", - "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==" + "integrity": "sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==", + "dev": true }, "prettier-plugin-hermes-parser": { "version": "0.20.1", diff --git a/packages/lexical-code/flow/LexicalCode.js.flow b/packages/lexical-code/flow/LexicalCode.js.flow index 9c5cf449acd..a3a0bac98f5 100644 --- a/packages/lexical-code/flow/LexicalCode.js.flow +++ b/packages/lexical-code/flow/LexicalCode.js.flow @@ -77,12 +77,6 @@ declare export class CodeHighlightNode extends TextNode { // $FlowFixMe static clone(node: CodeHighlightNode): CodeHighlightNode; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - // $FlowFixMe - prevNode: CodeHighlightNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; setFormat(format: number): this; } @@ -125,7 +119,6 @@ declare export class CodeNode extends ElementNode { static clone(node: CodeNode): CodeNode; constructor(language: ?string, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: CodeNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical-code/src/CodeHighlightNode.ts b/packages/lexical-code/src/CodeHighlightNode.ts index 15f08d207ab..c9b43e486d3 100644 --- a/packages/lexical-code/src/CodeHighlightNode.ts +++ b/packages/lexical-code/src/CodeHighlightNode.ts @@ -136,11 +136,7 @@ export class CodeHighlightNode extends TextNode { return element; } - updateDOM( - prevNode: CodeHighlightNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const update = super.updateDOM(prevNode, dom, config); const prevClassName = getHighlightThemeClass( config.theme, diff --git a/packages/lexical-code/src/CodeNode.ts b/packages/lexical-code/src/CodeNode.ts index 728e23e64b1..f2ae407c189 100644 --- a/packages/lexical-code/src/CodeNode.ts +++ b/packages/lexical-code/src/CodeNode.ts @@ -107,11 +107,7 @@ export class CodeNode extends ElementNode { } return element; } - updateDOM( - prevNode: CodeNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const language = this.__language; const prevLanguage = prevNode.__language; diff --git a/packages/lexical-link/flow/LexicalLink.js.flow b/packages/lexical-link/flow/LexicalLink.js.flow index cab496485ac..5e755f8e8aa 100644 --- a/packages/lexical-link/flow/LexicalLink.js.flow +++ b/packages/lexical-link/flow/LexicalLink.js.flow @@ -39,11 +39,6 @@ declare export class LinkNode extends ElementNode { static clone(node: LinkNode): LinkNode; constructor(url: string, attributes?: LinkAttributes, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - prevNode: LinkNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; static importDOM(): DOMConversionMap | null; exportJSON(): SerializedLinkNode; getURL(): string; diff --git a/packages/lexical-link/src/index.ts b/packages/lexical-link/src/index.ts index b2cdaefc89c..2149cf4a30c 100644 --- a/packages/lexical-link/src/index.ts +++ b/packages/lexical-link/src/index.ts @@ -113,11 +113,11 @@ export class LinkNode extends ElementNode { } updateDOM( - prevNode: LinkNode, + prevNode: this, anchor: LinkHTMLElementType, config: EditorConfig, ): boolean { - if (anchor instanceof HTMLAnchorElement) { + if (isHTMLAnchorElement(anchor)) { const url = this.__url; const target = this.__target; const rel = this.__rel; @@ -393,7 +393,7 @@ export class AutoLinkNode extends LinkNode { } updateDOM( - prevNode: AutoLinkNode, + prevNode: this, anchor: LinkHTMLElementType, config: EditorConfig, ): boolean { diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index f4fafcba71a..9dade3ae0a3 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -385,7 +385,7 @@ export class ListItemNode extends ElementNode { } canMergeWith(node: LexicalNode): boolean { - return $isParagraphNode(node) || $isListItemNode(node); + return $isListItemNode(node) || $isParagraphNode(node); } extractWithChild(child: LexicalNode, selection: BaseSelection): boolean { diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index 2af911c7a8e..dfcaacc1d1f 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -111,11 +111,7 @@ export class ListNode extends ElementNode { return dom; } - updateDOM( - prevNode: ListNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__tag !== this.__tag) { return true; } @@ -156,7 +152,7 @@ export class ListNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const element = this.createDOM(editor._config, editor); - if (element && isHTMLElement(element)) { + if (isHTMLElement(element)) { if (this.__start !== 1) { element.setAttribute('start', String(this.__start)); } diff --git a/packages/lexical-list/src/formatList.ts b/packages/lexical-list/src/formatList.ts index 3dc4a22ea20..46694253ebb 100644 --- a/packages/lexical-list/src/formatList.ts +++ b/packages/lexical-list/src/formatList.ts @@ -12,7 +12,6 @@ import { $getSelection, $isElementNode, $isLeafNode, - $isParagraphNode, $isRangeSelection, $isRootOrShadowRoot, ElementNode, @@ -494,10 +493,12 @@ export function $handleListInsertParagraph(): boolean { const grandparent = parent.getParent(); - let replacementNode; + let replacementNode: ParagraphNode | ListItemNode; if ($isRootOrShadowRoot(grandparent)) { replacementNode = $createParagraphNode(); + replacementNode.setTextStyle(selection.style); + replacementNode.setTextFormat(selection.format); topListNode.insertAfter(replacementNode); } else if ($isListItemNode(grandparent)) { replacementNode = $createListItemNode(); @@ -511,18 +512,14 @@ export function $handleListInsertParagraph(): boolean { if (nextSiblings.length > 0) { const newList = $createListNode(parent.getListType()); - - if ($isParagraphNode(replacementNode)) { - replacementNode.insertAfter(newList); - } else { + if ($isListItemNode(replacementNode)) { const newListItem = $createListItemNode(); newListItem.append(newList); replacementNode.insertAfter(newListItem); + } else { + replacementNode.insertAfter(newList); } - nextSiblings.forEach((sibling) => { - sibling.remove(); - newList.append(sibling); - }); + newList.append(...nextSiblings); } // Don't leave hanging nested empty lists diff --git a/packages/lexical-mark/src/MarkNode.ts b/packages/lexical-mark/src/MarkNode.ts index a19bd626fc7..2bc9ab140f0 100644 --- a/packages/lexical-mark/src/MarkNode.ts +++ b/packages/lexical-mark/src/MarkNode.ts @@ -78,7 +78,7 @@ export class MarkNode extends ElementNode { } updateDOM( - prevNode: MarkNode, + prevNode: this, element: HTMLElement, config: EditorConfig, ): boolean { diff --git a/packages/lexical-markdown/src/MarkdownExport.ts b/packages/lexical-markdown/src/MarkdownExport.ts index 0bdf3b71b8b..e26ce19db33 100644 --- a/packages/lexical-markdown/src/MarkdownExport.ts +++ b/packages/lexical-markdown/src/MarkdownExport.ts @@ -108,6 +108,8 @@ function exportChildren( ): string { const output = []; const children = node.getChildren(); + // keep track of unclosed tags from the very beginning + const unclosedTags: {format: TextFormatType; tag: string}[] = []; mainLoop: for (const child of children) { for (const transformer of textMatchTransformers) { @@ -124,7 +126,12 @@ function exportChildren( textMatchTransformers, ), (textNode, textContent) => - exportTextFormat(textNode, textContent, textTransformersIndex), + exportTextFormat( + textNode, + textContent, + textTransformersIndex, + unclosedTags, + ), ); if (result != null) { @@ -137,7 +144,12 @@ function exportChildren( output.push('\n'); } else if ($isTextNode(child)) { output.push( - exportTextFormat(child, child.getTextContent(), textTransformersIndex), + exportTextFormat( + child, + child.getTextContent(), + textTransformersIndex, + unclosedTags, + ), ); } else if ($isElementNode(child)) { // empty paragraph returns "" @@ -156,6 +168,8 @@ function exportTextFormat( node: TextNode, textContent: string, textTransformers: Array, + // unclosed tags include the markdown tags that haven't been closed yet, and their associated formats + unclosedTags: Array<{format: TextFormatType; tag: string}>, ): string { // This function handles the case of a string looking like this: " foo " // Where it would be invalid markdown to generate: "** foo **" @@ -163,6 +177,13 @@ function exportTextFormat( // bring the whitespace back. So our returned string looks like this: " **foo** " const frozenString = textContent.trim(); let output = frozenString; + // the opening tags to be added to the result + let openingTags = ''; + // the closing tags to be added to the result + let closingTags = ''; + + const prevNode = getTextSibling(node, true); + const nextNode = getTextSibling(node, false); const applied = new Set(); @@ -170,25 +191,40 @@ function exportTextFormat( const format = transformer.format[0]; const tag = transformer.tag; + // dedup applied formats if (hasFormat(node, format) && !applied.has(format)) { // Multiple tags might be used for the same format (*, _) applied.add(format); - // Prevent adding opening tag is already opened by the previous sibling - const previousNode = getTextSibling(node, true); - if (!hasFormat(previousNode, format)) { - output = tag + output; + // append the tag to openningTags, if it's not applied to the previous nodes, + // or the nodes before that (which would result in an unclosed tag) + if ( + !hasFormat(prevNode, format) || + !unclosedTags.find((element) => element.tag === tag) + ) { + unclosedTags.push({format, tag}); + openingTags += tag; } + } + } - // Prevent adding closing tag if next sibling will do it - const nextNode = getTextSibling(node, false); + // close any tags in the same order they were applied, if necessary + for (let i = 0; i < unclosedTags.length; i++) { + // prevent adding closing tag if next sibling will do it + if (hasFormat(nextNode, unclosedTags[i].format)) { + continue; + } - if (!hasFormat(nextNode, format)) { - output += tag; + while (unclosedTags.length > i) { + const unclosedTag = unclosedTags.pop(); + if (unclosedTag && typeof unclosedTag.tag === 'string') { + closingTags += unclosedTag.tag; } } + break; } + output = openingTags + output + closingTags; // Replace trimmed version of textContent ensuring surrounding whitespace is not modified return textContent.replace(frozenString, () => output); } diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index f78fc4a3056..0557bd09a10 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -354,7 +354,15 @@ describe('Markdown', () => { }, { html: '

Hello world!

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

Hello world!

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

Hello world!

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

Hello world!

', diff --git a/packages/lexical-overflow/flow/LexicalOverflow.js.flow b/packages/lexical-overflow/flow/LexicalOverflow.js.flow index 4ebd65ead94..d9e0a990aea 100644 --- a/packages/lexical-overflow/flow/LexicalOverflow.js.flow +++ b/packages/lexical-overflow/flow/LexicalOverflow.js.flow @@ -19,7 +19,6 @@ declare export class OverflowNode extends ElementNode { static clone(node: OverflowNode): OverflowNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: OverflowNode, dom: HTMLElement): boolean; insertNewAfter(selection: RangeSelection): null | LexicalNode; excludeFromCopy(): boolean; static importJSON(serializedNode: SerializedOverflowNode): OverflowNode; diff --git a/packages/lexical-overflow/src/index.ts b/packages/lexical-overflow/src/index.ts index 2b1986a5d6e..60e77d21ecb 100644 --- a/packages/lexical-overflow/src/index.ts +++ b/packages/lexical-overflow/src/index.ts @@ -58,7 +58,7 @@ export class OverflowNode extends ElementNode { return div; } - updateDOM(prevNode: OverflowNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs index 2a7c3156111..fe73b19f791 100644 --- a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs @@ -52,7 +52,12 @@ test.describe('Autocomplete', () => { class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"> Sort by alpha - +

`, ); @@ -118,7 +123,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -204,7 +214,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -241,7 +256,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); @@ -278,7 +298,12 @@ test.describe('Autocomplete', () => { data-lexical-text="true"> Test - +

`, ); diff --git a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs index 34dd9972778..dab99d16c5d 100644 --- a/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CodeBlock.spec.mjs @@ -366,7 +366,9 @@ test.describe('CodeBlock', () => { ;
- + @@ -393,7 +395,9 @@ test.describe('CodeBlock', () => { ;
- + @@ -453,7 +457,9 @@ test.describe('CodeBlock', () => { {
- + @@ -501,8 +507,12 @@ test.describe('CodeBlock', () => { data-gutter="123" data-highlight-language="javascript" data-language="javascript"> - - + + @@ -527,9 +537,15 @@ test.describe('CodeBlock', () => { {
- - - + + + @@ -551,8 +567,12 @@ test.describe('CodeBlock', () => { ;
- - + + @@ -575,7 +595,9 @@ test.describe('CodeBlock', () => { data-gutter="123" data-highlight-language="javascript" data-language="javascript"> - + @@ -600,8 +622,12 @@ test.describe('CodeBlock', () => { {
- - + + @@ -623,7 +649,9 @@ test.describe('CodeBlock', () => { ;
- + @@ -913,10 +941,10 @@ test.describe('CodeBlock', () => { data-gutter="12" data-language="javascript" data-highlight-language="javascript"> - + a b
- + c d `, 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 a2c9e0bf610..c0ef411ca34 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -106,6 +106,78 @@ test.describe('HTML Tables CopyAndPaste', () => { ); }); + test('Copy + paste (Table - Google Docs with custom widths)', async ({ + page, + isPlainText, + isCollab, + }) => { + test.skip(isPlainText || isCollab); + await focusEditor(page); + const clipboard = { + 'text/html': `

short

wide

default

a

b

c


`, + }; + await pasteFromClipboard(page, clipboard); + await assertHTML( + page, + html` + + + + + + + + + + + + + + + + +
+

+ short +

+
+

+ wide +

+
+

+ default +

+
+

+ a +

+
+

+ b +

+
+

+ c +

+
+


+ `, + ); + }); + test('Copy + paste (Table - Quip)', async ({page, isPlainText}) => { test.skip(isPlainText); @@ -204,9 +276,9 @@ test.describe('HTML Tables CopyAndPaste', () => { html` - - - + + + - + - - + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs index 989a4bbbeb8..60997995ceb 100644 --- a/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/KeyboardShortcuts.spec.mjs @@ -23,14 +23,17 @@ import { selectCharacters, toggleBold, toggleBulletList, + toggleCapitalize, toggleChecklist, toggleInsertCodeBlock, toggleItalic, + toggleLowercase, toggleNumberedList, toggleStrikethrough, toggleSubscript, toggleSuperscript, toggleUnderline, + toggleUppercase, } from '../keyboardShortcuts/index.mjs'; import { assertHTML, @@ -112,6 +115,18 @@ const alignmentTestCases = [ ]; const additionalStylesTestCases = [ + { + applyShortcut: (page) => toggleLowercase(page), + style: 'Lowercase', + }, + { + applyShortcut: (page) => toggleUppercase(page), + style: 'Uppercase', + }, + { + applyShortcut: (page) => toggleCapitalize(page), + style: 'Capitalize', + }, { applyShortcut: (page) => toggleStrikethrough(page), style: 'Strikethrough', diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs index 8c1703c15c9..df81baee7a2 100644 --- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs @@ -17,6 +17,7 @@ import { redo, selectAll, selectCharacters, + toggleBold, undo, } from '../keyboardShortcuts/index.mjs'; import { @@ -28,10 +29,10 @@ import { focusEditor, html, initialize, - IS_LINUX, pasteFromClipboard, repeat, selectFromAlignDropdown, + selectFromColorPicker, selectFromFormatDropdown, test, waitForSelector, @@ -72,60 +73,62 @@ test.beforeEach(({isPlainText}) => { test.describe.parallel('Nested List', () => { test.beforeEach(({isCollab, page}) => initialize({isCollab, page})); - test(`Can create a list and partially copy some content out of it`, async ({ - page, - isCollab, - }) => { - test.fixme(isCollab && IS_LINUX, 'Flaky on Linux + Collab'); - await focusEditor(page); - await page.keyboard.type( - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam venenatis risus ac cursus efficitur. Cras efficitur magna odio, lacinia posuere mauris placerat in. Etiam eu congue nisl. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nulla vulputate justo id eros convallis, vel pellentesque orci hendrerit. Pellentesque accumsan molestie eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor, et bibendum quam feugiat eget. Praesent vestibulum libero sed enim ornare, in consequat dui posuere. Maecenas ornare vestibulum felis, non elementum urna imperdiet sit amet.', - ); - await toggleBulletList(page); - await moveToEditorBeginning(page); - await moveRight(page, 6); - await selectCharacters(page, 'right', 11); + test( + `Can create a list and partially copy some content out of it`, + { + tag: '@flaky', + }, + async ({page, isCollab}) => { + await focusEditor(page); + await page.keyboard.type( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam venenatis risus ac cursus efficitur. Cras efficitur magna odio, lacinia posuere mauris placerat in. Etiam eu congue nisl. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Nulla vulputate justo id eros convallis, vel pellentesque orci hendrerit. Pellentesque accumsan molestie eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor, et bibendum quam feugiat eget. Praesent vestibulum libero sed enim ornare, in consequat dui posuere. Maecenas ornare vestibulum felis, non elementum urna imperdiet sit amet.', + ); + await toggleBulletList(page); + await moveToEditorBeginning(page); + await moveRight(page, 6); + await selectCharacters(page, 'right', 11); - await withExclusiveClipboardAccess(async () => { - const clipboard = await copyToClipboard(page); + await withExclusiveClipboardAccess(async () => { + const clipboard = await copyToClipboard(page); - await moveToEditorEnd(page); - await page.keyboard.press('Enter'); - await page.keyboard.press('Enter'); + await moveToEditorEnd(page); + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); - await pasteFromClipboard(page, clipboard); - }); + await pasteFromClipboard(page, clipboard); + }); - await assertHTML( - page, - html` -
    -
  • - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam - venenatis risus ac cursus efficitur. Cras efficitur magna odio, - lacinia posuere mauris placerat in. Etiam eu congue nisl. - Vestibulum ante ipsum primis in faucibus orci luctus et ultrices - posuere cubilia curae; Nulla vulputate justo id eros convallis, - vel pellentesque orci hendrerit. Pellentesque accumsan molestie - eros, vitae tempor nisl semper sit amet. Sed vulputate leo dolor, - et bibendum quam feugiat eget. Praesent vestibulum libero sed enim - ornare, in consequat dui posuere. Maecenas ornare vestibulum - felis, non elementum urna imperdiet sit amet. - -
  • -
-

- ipsum dolor -

- `, - ); - }); + await assertHTML( + page, + html` +
    +
  • + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam + venenatis risus ac cursus efficitur. Cras efficitur magna odio, + lacinia posuere mauris placerat in. Etiam eu congue nisl. + Vestibulum ante ipsum primis in faucibus orci luctus et ultrices + posuere cubilia curae; Nulla vulputate justo id eros convallis, + vel pellentesque orci hendrerit. Pellentesque accumsan molestie + eros, vitae tempor nisl semper sit amet. Sed vulputate leo + dolor, et bibendum quam feugiat eget. Praesent vestibulum libero + sed enim ornare, in consequat dui posuere. Maecenas ornare + vestibulum felis, non elementum urna imperdiet sit amet. + +
  • +
+

+ ipsum dolor +

+ `, + ); + }, + ); test('Should outdent if indented when the backspace key is pressed', async ({ page, @@ -158,6 +161,24 @@ test.describe.parallel('Nested List', () => { ); }); + test('Should retain selection style when exiting list', async ({page}) => { + await focusEditor(page); + await toggleBulletList(page); + + await selectFromColorPicker(page); + await toggleBold(page); + await page.keyboard.type('Hello'); + //Double-enter to exit list + await page.keyboard.press('Enter'); + await page.keyboard.press('Enter'); + await page.keyboard.type('World'); + + await assertHTML( + page, + '
  • Hello

World

', + ); + }); + test(`Can indent/outdent mutliple list nodes in a list with multiple levels of indentation`, async ({ page, }) => { diff --git a/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs index 118e39536d4..b9198e193de 100644 --- a/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tab.spec.mjs @@ -81,7 +81,9 @@ test.describe('Tab', () => { dir="ltr" style="padding-inline-start: calc(40px)"> すし - + すし

`, @@ -106,7 +108,9 @@ test.describe('Tab', () => { data-gutter="1" data-highlight-language="javascript" data-language="javascript"> - + diff --git a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs index 4fd7ca25b30..4bbc39f4678 100644 --- a/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Tables.spec.mjs @@ -34,6 +34,7 @@ import { insertSampleImage, insertTable, insertTableColumnBefore, + insertTableRowAbove, insertTableRowBelow, IS_COLLAB, IS_LINUX, @@ -105,8 +106,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -149,8 +150,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -197,8 +198,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -243,8 +244,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -288,8 +289,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -316,8 +317,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -348,8 +349,9 @@ test.describe.parallel('Tables', () => { browserName, legacyEvents, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); + // Table edge cursor doesn't show up in Firefox. test.fixme(browserName === 'firefox'); test.fixme( @@ -473,13 +475,14 @@ test.describe.parallel('Tables', () => { isCollab, browserName, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); // Table edge cursor doesn't show up in Firefox. test.fixme(browserName === 'firefox'); // After typing, the dom selection gets set back to the internal previous selection during the update. test.fixme(LEGACY_EVENTS); + await initialize({isCollab, page}); + await focusEditor(page); await insertTable(page, 2, 2); @@ -533,8 +536,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -562,8 +565,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -661,8 +664,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -696,8 +699,8 @@ test.describe.parallel('Tables', () => { isCollab, isPlainText, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 2); @@ -733,8 +736,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -827,8 +830,8 @@ test.describe.parallel('Tables', () => { isCollab, browserName, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 3, 3); @@ -837,7 +840,7 @@ test.describe.parallel('Tables', () => { let p = page; - if (IS_COLLAB) { + if (isCollab) { await focusEditor(page); p = await page.frame('left'); } @@ -958,8 +961,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -1056,8 +1059,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -1151,8 +1154,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -1256,8 +1259,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -1351,8 +1354,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -1415,8 +1418,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 1, 2); @@ -1473,8 +1476,8 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await page.keyboard.type('Hello World'); @@ -1560,8 +1563,8 @@ test.describe.parallel('Tables', () => { isCollab, isPlainText, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await page.keyboard.type('Text before'); await page.keyboard.press('Enter'); @@ -1621,8 +1624,8 @@ test.describe.parallel('Tables', () => { }); test(`Horizontal rule inside cell`, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 1, 2); @@ -1657,13 +1660,13 @@ test.describe.parallel('Tables', () => { }); test( - 'Grid selection: can select multiple cells and insert an image', + 'Table selection: can select multiple cells and insert an image', { tag: '@flaky', }, async ({page, isPlainText, isCollab, browserName}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); @@ -1741,13 +1744,13 @@ test.describe.parallel('Tables', () => { }, ); - test('Grid selection: can backspace lines, backspacing empty cell does not destroy it #3278', async ({ + test('Table selection: can backspace lines, backspacing empty cell does not destroy it #3278', async ({ page, isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); @@ -1833,8 +1836,8 @@ test.describe.parallel('Tables', () => { isCollab, browserName, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); @@ -1938,13 +1941,14 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.fixme( isCollab && IS_LINUX && browserName === 'firefox', 'Flaky on Linux + Collab', ); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2024,9 +2028,10 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2107,10 +2112,11 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); test.fixme(IS_COLLAB && IS_LINUX && browserName === 'firefox'); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2196,9 +2202,10 @@ test.describe.parallel('Tables', () => { }); test('Merge/unmerge cells (1)', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2295,9 +2302,10 @@ test.describe.parallel('Tables', () => { }); test('Merge/unmerge cells (2)', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2442,9 +2450,9 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); test.skip(isCollab); + await initialize({isCollab, page}); await focusEditor(page); @@ -2590,9 +2598,9 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); test.skip(isCollab); + await initialize({isCollab, page}); await focusEditor(page); @@ -2736,9 +2744,10 @@ test.describe.parallel('Tables', () => { }); test('Merge with content', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -2837,8 +2846,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); @@ -2974,8 +2983,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); @@ -3118,9 +3127,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3185,9 +3195,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3250,9 +3261,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3309,9 +3321,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3386,9 +3399,10 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3452,9 +3466,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3518,9 +3533,10 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3571,9 +3587,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3623,9 +3640,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3647,9 +3665,10 @@ test.describe.parallel('Tables', () => { }); test('Background color to cell', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3683,8 +3702,8 @@ test.describe.parallel('Tables', () => { }); test('Cell merge feature disabled', async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page, tableCellMerge: false}); test.skip(isPlainText); + await initialize({isCollab, page, tableCellMerge: false}); await focusEditor(page); await pasteFromClipboard(page, { @@ -3737,7 +3756,9 @@ test.describe.parallel('Tables', () => { Hello world


+


+

{



+


+
+


+

{ isPlainText, isCollab, }) => { - await initialize({isCollab, page, tableCellBackgroundColor: false}); test.skip(isPlainText); + await initialize({isCollab, page, tableCellBackgroundColor: false}); await focusEditor(page); await pasteFromClipboard(page, { @@ -3834,9 +3859,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -3935,8 +3961,8 @@ test.describe.parallel('Tables', () => { tag: '@flaky', }, async ({page, isPlainText, isCollab}) => { - await initialize({isCollab, page}); test.skip(isPlainText); + await initialize({isCollab, page}); await focusEditor(page); await insertTable(page, 2, 3); @@ -4054,9 +4080,10 @@ test.describe.parallel('Tables', () => { isPlainText, isCollab, }) => { - await initialize({isCollab, page}); test.skip(isPlainText); - if (IS_COLLAB) { + await initialize({isCollab, page}); + + if (isCollab) { // The contextual menu positioning needs fixing (it's hardcoded to show on the right side) page.setViewportSize({height: 1000, width: 3000}); } @@ -4225,4 +4252,996 @@ test.describe.parallel('Tables', () => { `, ); }); + + test('Can delete table row when previous cell is a merged cell', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + await initialize({isCollab, page}); + + await focusEditor(page); + + await insertTable(page, 5, 5); + + await selectCellsFromTableCords( + page, + {x: 1, y: 1}, + {x: 1, y: 3}, + false, + false, + ); + await mergeTableCells(page); + await selectCellsFromTableCords( + page, + {x: 1, y: 2}, + {x: 2, y: 4}, + false, + false, + ); + await mergeTableCells(page); + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


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


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + + test('Can delete table row when siblings are merged cell', async ({ + page, + isCollab, + isPlainText, + }) => { + test.skip(isPlainText); + await initialize({isCollab, page}); + + await focusEditor(page); + + await insertTable(page, 5, 5); + + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 0, y: 3}, + true, + true, + ); + await mergeTableCells(page); + await selectCellsFromTableCords( + page, + {x: 2, y: 0}, + {x: 1, y: 2}, + true, + false, + ); + await mergeTableCells(page); + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + + await selectCellsFromTableCords( + page, + {x: 0, y: 2}, + {x: 0, y: 2}, + false, + false, + ); + + await deleteTableRows(page); + + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + + test('Can insert multiple rows above the selection', async ({ + page, + isCollab, + isPlainText, + }) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + test.skip(isCollab); + + await focusEditor(page); + + await insertTable(page, 5, 5); + + await selectCellsFromTableCords( + page, + {x: 0, y: 1}, + {x: 4, y: 3}, + true, + false, + ); + + await insertTableRowAbove(page); + + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + + test('Can insert multiple rows below the selection', async ({ + page, + isCollab, + isPlainText, + }) => { + await initialize({isCollab, page}); + test.skip(isPlainText); + test.skip(isCollab); + + await focusEditor(page); + + await insertTable(page, 5, 5); + + await selectCellsFromTableCords( + page, + {x: 0, y: 1}, + {x: 4, y: 3}, + true, + false, + ); + + await insertTableRowBelow(page); + + await assertHTML( + page, + html` +


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


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+
+


+ `, + ); + }); + test.describe('with context menu', () => { + test.use({shouldUseLexicalContextMenu: true}); + test(`Can select cells using Table selection and cut them with the context menu`, async ({ + page, + isPlainText, + isCollab, + shouldUseLexicalContextMenu, + browserName, + }) => { + test.skip(isPlainText); + // The way that the clicks happen in test doesn't work in firefox for some reason + // but it does seem to work when you do it by hand + test.fixme(browserName === 'firefox'); + await initialize({isCollab, page, shouldUseLexicalContextMenu}); + + await focusEditor(page); + await insertTable(page, 2, 3); + + await fillTablePartiallyWithText(page); + await selectCellsFromTableCords( + page, + {x: 0, y: 0}, + {x: 1, y: 1}, + true, + false, + ); + + await assertHTML( + page, + html` +


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

a

+
+

bb

+
+

cc

+
+

d

+
+

e

+
+

f

+
+


+ `, + html` +


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

a

+
+

bb

+
+

cc

+
+

d

+
+

e

+
+

f

+
+


+ `, + {ignoreClasses: true}, + ); + + await withExclusiveClipboardAccess(async () => { + await click(page, 'div[contenteditable] th p', { + button: 'right', + }); + await click(page, '#typeahead-menu [role="option"] :text("Cut")'); + }); + + await assertHTML( + page, + html` +


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


+
+


+
+

cc

+
+


+
+


+
+

f

+
+


+ `, + undefined, + {ignoreClasses: true}, + ); + }); + }); }); diff --git a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs index 528cec14d12..2727f72b27c 100644 --- a/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/TextFormatting.spec.mjs @@ -13,8 +13,11 @@ import { moveToLineEnd, selectCharacters, toggleBold, + toggleCapitalize, toggleItalic, + toggleLowercase, toggleUnderline, + toggleUppercase, } from '../keyboardShortcuts/index.mjs'; import { assertHTML, @@ -428,6 +431,145 @@ test.describe.parallel('TextFormatting', () => { }); }); + const capitalizationFormats = [ + { + applyCapitalization: toggleLowercase, + className: 'PlaygroundEditorTheme__textLowercase', + format: 'lowercase', + }, + { + applyCapitalization: toggleUppercase, + className: 'PlaygroundEditorTheme__textUppercase', + format: 'uppercase', + }, + { + applyCapitalization: toggleCapitalize, + className: 'PlaygroundEditorTheme__textCapitalize', + format: 'capitalize', + }, + ]; + + capitalizationFormats.forEach(({className, format, applyCapitalization}) => { + test(`Can select text and change it to ${format}`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + await page.keyboard.type('Hello world!'); + await moveLeft(page); + await selectCharacters(page, 'left', 5); + + await assertSelection(page, { + anchorOffset: 11, + anchorPath: [0, 0, 0], + focusOffset: 6, + focusPath: [0, 0, 0], + }); + + await applyCapitalization(page); + await assertHTML( + page, + html` +

+ Hello + world + ! +

+ `, + ); + + await assertSelection(page, { + anchorOffset: 5, + anchorPath: [0, 1, 0], + focusOffset: 0, + focusPath: [0, 1, 0], + }); + }); + }); + + const capitalizationResettingTestCases = [ + { + expectedFinalHTML: html` +

+ Hello + world! +

+ `, + key: 'Space', + }, + { + expectedFinalHTML: html` +

+ Hello + + world! +

+ `, + key: 'Tab', + }, + { + expectedFinalHTML: html` +

+ Hello +

+

+ world! +

+ `, + key: 'Enter', + }, + ]; + + capitalizationFormats.forEach(({format, className, applyCapitalization}) => { + capitalizationResettingTestCases.forEach(({key, expectedFinalHTML}) => { + test(`Pressing ${key} resets ${format} format`, async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + + await focusEditor(page); + + await applyCapitalization(page); + await page.keyboard.type('Hello'); + + await assertHTML( + page, + html` +

+ Hello +

+ `, + ); + + // Pressing the key should reset the format + await page.keyboard.press(key); + await page.keyboard.type(' world!'); + + await assertHTML( + page, + expectedFinalHTML.replace('$formatClassName', className), + ); + }); + }); + }); + test(`Can select text and increase the font-size`, async ({ page, isPlainText, diff --git a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs index 41893cd7900..e4bef9db645 100644 --- a/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs +++ b/packages/lexical-playground/__tests__/keyboardShortcuts/index.mjs @@ -261,6 +261,30 @@ export async function toggleInsertCodeBlock(page) { await page.keyboard.up('Shift'); } +export async function toggleLowercase(page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('1'); + await keyUpCtrlOrMeta(page); + await page.keyboard.up('Shift'); +} + +export async function toggleUppercase(page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('2'); + await keyUpCtrlOrMeta(page); + await page.keyboard.up('Shift'); +} + +export async function toggleCapitalize(page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('3'); + await keyUpCtrlOrMeta(page); + await page.keyboard.up('Shift'); +} + export async function toggleStrikethrough(page) { await keyDownCtrlOrMeta(page); await page.keyboard.down('Shift'); diff --git a/packages/lexical-playground/__tests__/utils/index.mjs b/packages/lexical-playground/__tests__/utils/index.mjs index ed81a581866..a9e13d587ec 100644 --- a/packages/lexical-playground/__tests__/utils/index.mjs +++ b/packages/lexical-playground/__tests__/utils/index.mjs @@ -225,7 +225,7 @@ async function assertHTMLOnPageOrFrame( frameName, actualHtmlModificationsCallback = (actualHtml) => actualHtml, ) { - const expected = prettifyHTML(expectedHtml.replace(/\n/gm, ''), { + const expected = await prettifyHTML(expectedHtml.replace(/\n/gm, ''), { ignoreClasses, ignoreInlineStyles, }); @@ -236,7 +236,7 @@ async function assertHTMLOnPageOrFrame( .first() .innerHTML(), ); - let actual = prettifyHTML(actualHtml.replace(/\n/gm, ''), { + let actual = await prettifyHTML(actualHtml.replace(/\n/gm, ''), { ignoreClasses, ignoreInlineStyles, }); @@ -780,7 +780,10 @@ export async function dragImage( ); } -export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) { +export async function prettifyHTML( + string, + {ignoreClasses, ignoreInlineStyles} = {}, +) { let output = string; if (ignoreClasses) { @@ -793,15 +796,14 @@ export function prettifyHTML(string, {ignoreClasses, ignoreInlineStyles} = {}) { output = output.replace(/\s__playwright_target__="[^"]+"/, ''); - return prettier - .format(output, { - attributeGroups: ['$DEFAULT', '^data-'], - attributeSort: 'ASC', - bracketSameLine: true, - htmlWhitespaceSensitivity: 'ignore', - parser: 'html', - }) - .trim(); + return await prettier.format(output, { + attributeGroups: ['$DEFAULT', '^data-'], + attributeSort: 'asc', + bracketSameLine: true, + htmlWhitespaceSensitivity: 'ignore', + parser: 'html', + plugins: ['prettier-plugin-organize-attributes'], + }); } // This function does not suppose to do anything, it's only used as a trigger diff --git a/packages/lexical-playground/package.json b/packages/lexical-playground/package.json index cd7ced6303f..5a2751577cf 100644 --- a/packages/lexical-playground/package.json +++ b/packages/lexical-playground/package.json @@ -29,7 +29,7 @@ "katex": "^0.16.10", "lexical": "0.21.0", "lodash-es": "^4.17.21", - "prettier": "^2.3.2", + "prettier": "^3.4.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", diff --git a/packages/lexical-playground/src/context/ToolbarContext.tsx b/packages/lexical-playground/src/context/ToolbarContext.tsx index 266c584f7ef..f8b1c1f082b 100644 --- a/packages/lexical-playground/src/context/ToolbarContext.tsx +++ b/packages/lexical-playground/src/context/ToolbarContext.tsx @@ -40,6 +40,8 @@ export const blockTypeToBlockName = { quote: 'Quote', }; +//disable eslint sorting rule for quick reference to toolbar state +/* eslint-disable sort-keys-fix/sort-keys-fix */ const INITIAL_TOOLBAR_STATE = { bgColor: '#fff', blockType: 'paragraph' as keyof typeof blockTypeToBlockName, @@ -63,6 +65,9 @@ const INITIAL_TOOLBAR_STATE = { isSubscript: false, isSuperscript: false, isUnderline: false, + isLowercase: false, + isUppercase: false, + isCapitalize: false, rootType: 'root' as keyof typeof rootTypeToRootName, }; diff --git a/packages/lexical-playground/src/images/icons/type-capitalize.svg b/packages/lexical-playground/src/images/icons/type-capitalize.svg new file mode 100644 index 00000000000..359fcd0707c --- /dev/null +++ b/packages/lexical-playground/src/images/icons/type-capitalize.svg @@ -0,0 +1 @@ + diff --git a/packages/lexical-playground/src/images/icons/type-lowercase.svg b/packages/lexical-playground/src/images/icons/type-lowercase.svg new file mode 100644 index 00000000000..5d097d7a57b --- /dev/null +++ b/packages/lexical-playground/src/images/icons/type-lowercase.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/images/icons/type-uppercase.svg b/packages/lexical-playground/src/images/icons/type-uppercase.svg new file mode 100644 index 00000000000..d0887b5d2f5 --- /dev/null +++ b/packages/lexical-playground/src/images/icons/type-uppercase.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 87fb8fdbbff..d62dd871509 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -395,6 +395,18 @@ i.underline { background-image: url(images/icons/type-underline.svg); } +i.uppercase { + background-image: url(images/icons/type-uppercase.svg); +} + +i.lowercase { + background-image: url(images/icons/type-lowercase.svg); +} + +i.capitalize { + background-image: url(images/icons/type-capitalize.svg); +} + i.strikethrough { background-image: url(images/icons/type-strikethrough.svg); } @@ -1766,28 +1778,6 @@ button.item.dropdown-item-active i { z-index: 3; } -.PlaygroundEditorTheme__blockCursor { - display: block; - pointer-events: none; - position: absolute; -} - -.PlaygroundEditorTheme__blockCursor:after { - content: ''; - display: block; - position: absolute; - top: -2px; - width: 20px; - border-top: 1px solid black; - animation: CursorBlink 1.1s steps(2, start) infinite; -} - -@keyframes CursorBlink { - to { - visibility: hidden; - } -} - .dialog-dropdown { background-color: #eee !important; margin-bottom: 10px; diff --git a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx index 220add6396c..777f0d69ab6 100644 --- a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx +++ b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx @@ -73,11 +73,7 @@ export class AutocompleteNode extends TextNode { this.__uuid = uuid; } - updateDOM( - prevNode: unknown, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { return false; } @@ -85,12 +81,16 @@ export class AutocompleteNode extends TextNode { return {element: null}; } + excludeFromCopy() { + return true; + } + createDOM(config: EditorConfig): HTMLElement { - if (this.__uuid !== UUID) { - return document.createElement('span'); - } const dom = super.createDOM(config); dom.classList.add(config.theme.autocomplete); + if (this.__uuid !== UUID) { + dom.style.display = 'none'; + } return dom; } } @@ -99,5 +99,5 @@ export function $createAutocompleteNode( text: string, uuid: string, ): AutocompleteNode { - return new AutocompleteNode(text, uuid); + return new AutocompleteNode(text, uuid).setMode('token'); } diff --git a/packages/lexical-playground/src/nodes/EmojiNode.tsx b/packages/lexical-playground/src/nodes/EmojiNode.tsx index 3c1a56874b4..30b899666d1 100644 --- a/packages/lexical-playground/src/nodes/EmojiNode.tsx +++ b/packages/lexical-playground/src/nodes/EmojiNode.tsx @@ -48,11 +48,7 @@ export class EmojiNode extends TextNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const inner = dom.firstChild; if (inner === null) { return true; diff --git a/packages/lexical-playground/src/nodes/EquationNode.tsx b/packages/lexical-playground/src/nodes/EquationNode.tsx index 373b821f988..1ab7cce2128 100644 --- a/packages/lexical-playground/src/nodes/EquationNode.tsx +++ b/packages/lexical-playground/src/nodes/EquationNode.tsx @@ -128,7 +128,7 @@ export class EquationNode extends DecoratorNode { }; } - updateDOM(prevNode: EquationNode): boolean { + updateDOM(prevNode: this): boolean { // If the inline property changes, replace the element return this.__inline !== prevNode.__inline; } diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx index 5a45068469e..259935a41f6 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx @@ -18,6 +18,7 @@ import { $getNodeByKey, CLICK_COMMAND, COMMAND_PRIORITY_LOW, + isDOMNode, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical'; @@ -86,7 +87,11 @@ export default function ExcalidrawComponent({ return true; } - if (buttonElem !== null && buttonElem.contains(eventTarget as Node)) { + if ( + buttonElem !== null && + isDOMNode(eventTarget) && + buttonElem.contains(eventTarget) + ) { if (!event.shiftKey) { clearSelection(); } diff --git a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx index 3ed9eca084b..df78864ca00 100644 --- a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx +++ b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageNode.tsx @@ -19,7 +19,12 @@ import type { Spread, } from 'lexical'; -import {$applyNodeReplacement, createEditor, DecoratorNode} from 'lexical'; +import { + $applyNodeReplacement, + createEditor, + DecoratorNode, + isHTMLElement, +} from 'lexical'; import * as React from 'react'; import {Suspense} from 'react'; @@ -45,8 +50,8 @@ export interface UpdateInlineImagePayload { } function $convertInlineImageElement(domNode: Node): null | DOMConversionOutput { - if (domNode instanceof HTMLImageElement) { - const {alt: altText, src, width, height} = domNode; + if (isHTMLElement(domNode) && domNode.nodeName === 'IMG') { + const {alt: altText, src, width, height} = domNode as HTMLImageElement; const node = $createInlineImageNode({altText, height, src, width}); return {node}; } @@ -230,11 +235,7 @@ export class InlineImageNode extends DecoratorNode { return span; } - updateDOM( - prevNode: InlineImageNode, - dom: HTMLElement, - config: EditorConfig, - ): false { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): false { const position = this.__position; if (position !== prevNode.__position) { const className = `${config.theme.inlineImage} position-${position}`; diff --git a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts index b89eed53b89..8bb7cddf47a 100644 --- a/packages/lexical-playground/src/nodes/LayoutContainerNode.ts +++ b/packages/lexical-playground/src/nodes/LayoutContainerNode.ts @@ -73,7 +73,7 @@ export class LayoutContainerNode extends ElementNode { return {element}; } - updateDOM(prevNode: LayoutContainerNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { if (prevNode.__templateColumns !== this.__templateColumns) { dom.style.gridTemplateColumns = this.__templateColumns; } diff --git a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx index 474241d15d4..8c89106f7cf 100644 --- a/packages/lexical-playground/src/nodes/SpecialTextNode.tsx +++ b/packages/lexical-playground/src/nodes/SpecialTextNode.tsx @@ -37,11 +37,7 @@ export class SpecialTextNode extends TextNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__text.startsWith('[') && prevNode.__text.endsWith(']')) { const strippedText = this.__text.substring(1, this.__text.length - 1); // Strip brackets again dom.textContent = strippedText; // Update the text content diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx index 203e63702b9..c1e47f23ec4 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx @@ -10,7 +10,6 @@ import './index.css'; import {$isCodeNode} from '@lexical/code'; import {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical'; import {Options} from 'prettier'; -import * as React from 'react'; import {useState} from 'react'; interface Props { @@ -20,17 +19,27 @@ interface Props { } const PRETTIER_PARSER_MODULES = { - css: () => import('prettier/parser-postcss'), - html: () => import('prettier/parser-html'), - js: () => import('prettier/parser-babel'), - markdown: () => import('prettier/parser-markdown'), + css: [() => import('prettier/parser-postcss')], + html: [() => import('prettier/parser-html')], + js: [ + () => import('prettier/parser-babel'), + () => import('prettier/plugins/estree'), + ], + markdown: [() => import('prettier/parser-markdown')], + typescript: [ + () => import('prettier/parser-typescript'), + () => import('prettier/plugins/estree'), + ], } as const; type LanguagesType = keyof typeof PRETTIER_PARSER_MODULES; async function loadPrettierParserByLang(lang: string) { - const dynamicImport = PRETTIER_PARSER_MODULES[lang as LanguagesType]; - return await dynamicImport(); + const dynamicImports = PRETTIER_PARSER_MODULES[lang as LanguagesType]; + const modules = await Promise.all( + dynamicImports.map((dynamicImport) => dynamicImport()), + ); + return modules; } async function loadPrettierFormat() { @@ -39,18 +48,11 @@ async function loadPrettierFormat() { } const PRETTIER_OPTIONS_BY_LANG: Record = { - css: { - parser: 'css', - }, - html: { - parser: 'html', - }, - js: { - parser: 'babel', - }, - markdown: { - parser: 'markdown', - }, + css: {parser: 'css'}, + html: {parser: 'html'}, + js: {parser: 'babel'}, + markdown: {parser: 'markdown'}, + typescript: {parser: 'typescript'}, }; const LANG_CAN_BE_PRETTIER = Object.keys(PRETTIER_OPTIONS_BY_LANG); @@ -76,36 +78,37 @@ export function PrettierButton({lang, editor, getCodeDOMNode}: Props) { async function handleClick(): Promise { const codeDOMNode = getCodeDOMNode(); + if (!codeDOMNode) { + return; + } + + let content = ''; + editor.update(() => { + const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); + if ($isCodeNode(codeNode)) { + content = codeNode.getTextContent(); + } + }); + if (content === '') { + return; + } try { const format = await loadPrettierFormat(); const options = getPrettierOptions(lang); - options.plugins = [await loadPrettierParserByLang(lang)]; - - if (!codeDOMNode) { - return; - } + const prettierParsers = await loadPrettierParserByLang(lang); + options.plugins = prettierParsers.map( + (parser) => parser.default || parser, + ); + const formattedCode = await format(content, options); editor.update(() => { const codeNode = $getNearestNodeFromDOMNode(codeDOMNode); - if ($isCodeNode(codeNode)) { - const content = codeNode.getTextContent(); - - let parsed = ''; - - try { - parsed = format(content, options); - } catch (error: unknown) { - setError(error); - } - - if (parsed !== '') { - const selection = codeNode.select(0); - selection.insertText(parsed); - setSyntaxError(''); - setTipsVisible(false); - } + const selection = codeNode.select(0); + selection.insertText(formattedCode); + setSyntaxError(''); + setTipsVisible(false); } }); } catch (error: unknown) { diff --git a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx index b200b279e69..e0a795a6a05 100644 --- a/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/CodeActionMenuPlugin/index.tsx @@ -15,7 +15,7 @@ import { normalizeCodeLang, } from '@lexical/code'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {$getNearestNodeFromDOMNode} from 'lexical'; +import {$getNearestNodeFromDOMNode, isHTMLElement} from 'lexical'; import {useEffect, useRef, useState} from 'react'; import * as React from 'react'; import {createPortal} from 'react-dom'; @@ -163,7 +163,7 @@ function getMouseInfo(event: MouseEvent): { } { const target = event.target; - if (target && target instanceof HTMLElement) { + if (isHTMLElement(target)) { const codeDOMNode = target.closest( 'code.PlaygroundEditorTheme__code', ); diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts index 1ade6a71cbc..6d4387e0a59 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts @@ -79,10 +79,7 @@ export class CollapsibleContainerNode extends ElementNode { return dom; } - updateDOM( - prevNode: CollapsibleContainerNode, - dom: HTMLDetailsElement, - ): boolean { + updateDOM(prevNode: this, dom: HTMLDetailsElement): boolean { const currentOpen = this.__open; if (prevNode.__open !== currentOpen) { // details is not well supported in Chrome #5582 diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts index 427d22bfd72..f6f4ce07ddd 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleContentNode.ts @@ -72,7 +72,7 @@ export class CollapsibleContentNode extends ElementNode { return dom; } - updateDOM(prevNode: CollapsibleContentNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts index d2e0488e09e..3b6a39061b3 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts @@ -62,7 +62,7 @@ export class CollapsibleTitleNode extends ElementNode { return dom; } - updateDOM(prevNode: CollapsibleTitleNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } diff --git a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx index 2404f88dca9..0cb2730b632 100644 --- a/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/FloatingTextFormatToolbarPlugin/index.tsx @@ -39,6 +39,9 @@ function TextFormatFloatingToolbar({ isBold, isItalic, isUnderline, + isUppercase, + isLowercase, + isCapitalize, isCode, isStrikethrough, isSubscript, @@ -51,6 +54,9 @@ function TextFormatFloatingToolbar({ isCode: boolean; isItalic: boolean; isLink: boolean; + isUppercase: boolean; + isLowercase: boolean; + isCapitalize: boolean; isStrikethrough: boolean; isSubscript: boolean; isSuperscript: boolean; @@ -193,6 +199,7 @@ function TextFormatFloatingToolbar({ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold'); }} className={'popup-item spaced ' + (isBold ? 'active' : '')} + title="Bold" aria-label="Format text as bold"> @@ -202,6 +209,7 @@ function TextFormatFloatingToolbar({ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); }} className={'popup-item spaced ' + (isItalic ? 'active' : '')} + title="Italic" aria-label="Format text as italics"> @@ -211,6 +219,7 @@ function TextFormatFloatingToolbar({ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); }} className={'popup-item spaced ' + (isUnderline ? 'active' : '')} + title="Underline" aria-label="Format text to underlined"> @@ -220,6 +229,7 @@ function TextFormatFloatingToolbar({ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); }} className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')} + title="Strikethrough" aria-label="Format text with a strikethrough"> @@ -243,12 +253,43 @@ function TextFormatFloatingToolbar({ aria-label="Format Superscript"> + + + @@ -256,6 +297,7 @@ function TextFormatFloatingToolbar({ type="button" onClick={insertLink} className={'popup-item spaced ' + (isLink ? 'active' : '')} + title="Insert link" aria-label="Insert link"> @@ -265,6 +307,7 @@ function TextFormatFloatingToolbar({ type="button" onClick={insertComment} className={'popup-item spaced insert-comment'} + title="Insert comment" aria-label="Insert comment"> @@ -282,6 +325,9 @@ function useFloatingTextFormatToolbar( const [isBold, setIsBold] = useState(false); const [isItalic, setIsItalic] = useState(false); const [isUnderline, setIsUnderline] = useState(false); + const [isUppercase, setIsUppercase] = useState(false); + const [isLowercase, setIsLowercase] = useState(false); + const [isCapitalize, setIsCapitalize] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false); const [isSubscript, setIsSubscript] = useState(false); const [isSuperscript, setIsSuperscript] = useState(false); @@ -317,6 +363,9 @@ function useFloatingTextFormatToolbar( setIsBold(selection.hasFormat('bold')); setIsItalic(selection.hasFormat('italic')); setIsUnderline(selection.hasFormat('underline')); + setIsUppercase(selection.hasFormat('uppercase')); + setIsLowercase(selection.hasFormat('lowercase')); + setIsCapitalize(selection.hasFormat('capitalize')); setIsStrikethrough(selection.hasFormat('strikethrough')); setIsSubscript(selection.hasFormat('subscript')); setIsSuperscript(selection.hasFormat('superscript')); @@ -378,6 +427,9 @@ function useFloatingTextFormatToolbar( isLink={isLink} isBold={isBold} isItalic={isItalic} + isUppercase={isUppercase} + isLowercase={isLowercase} + isCapitalize={isCapitalize} isStrikethrough={isStrikethrough} isSubscript={isSubscript} isSuperscript={isSuperscript} diff --git a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx index b2c2120220e..4fb80b2fab2 100644 --- a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx @@ -23,6 +23,7 @@ import { DRAGSTART_COMMAND, DROP_COMMAND, getDOMSelection, + isHTMLElement, LexicalCommand, LexicalEditor, } from 'lexical'; @@ -357,10 +358,9 @@ declare global { function canDropImage(event: DragEvent): boolean { const target = event.target; return !!( - target && - target instanceof HTMLElement && + isHTMLElement(target) && !target.closest('code, span.editor-image') && - target.parentElement && + isHTMLElement(target.parentElement) && target.parentElement.closest('div.ContentEditable__root') ); } diff --git a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx index 98cea7f6888..4ba2f6b40d6 100644 --- a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx @@ -27,6 +27,7 @@ import { DRAGSTART_COMMAND, DROP_COMMAND, getDOMSelection, + isHTMLElement, LexicalCommand, LexicalEditor, } from 'lexical'; @@ -309,10 +310,9 @@ declare global { function canDropImage(event: DragEvent): boolean { const target = event.target; return !!( - target && - target instanceof HTMLElement && + isHTMLElement(target) && !target.closest('code, span.editor-image') && - target.parentElement && + isHTMLElement(target.parentElement) && target.parentElement.closest('div.ContentEditable__root') ); } diff --git a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx index cbdeee1fe23..dd226849e62 100644 --- a/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx +++ b/packages/lexical-playground/src/plugins/LayoutPlugin/LayoutPlugin.tsx @@ -97,6 +97,25 @@ export function LayoutPlugin(): null { return false; }; + const $fillLayoutItemIfEmpty = (node: LayoutItemNode) => { + if (node.isEmpty()) { + node.append($createParagraphNode()); + } + }; + + const $removeIsolatedLayoutItem = (node: LayoutItemNode): boolean => { + const parent = node.getParent(); + if (!$isLayoutContainerNode(parent)) { + const children = node.getChildren(); + for (const child of children) { + node.insertBefore(child); + } + node.remove(); + return true; + } + return false; + }; + return mergeRegister( // When layout is the last child pressing down/right arrow will insert paragraph // below it to allow adding more content. It's similar what $insertBlockNode @@ -186,17 +205,17 @@ export function LayoutPlugin(): null { }, COMMAND_PRIORITY_EDITOR, ), - // Structure enforcing transformers for each node type. In case nesting structure is not - // "Container > Item" it'll unwrap nodes and convert it back - // to regular content. + editor.registerNodeTransform(LayoutItemNode, (node) => { - const parent = node.getParent(); - if (!$isLayoutContainerNode(parent)) { - const children = node.getChildren(); - for (const child of children) { - node.insertBefore(child); - } - node.remove(); + // Structure enforcing transformers for each node type. In case nesting structure is not + // "Container > Item" it'll unwrap nodes and convert it back + // to regular content. + const isRemoved = $removeIsolatedLayoutItem(node); + + if (!isRemoved) { + // Layout item should always have a child. this function will listen + // for any empty layout item and fill it with a paragraph node + $fillLayoutItemIfEmpty(node); } }), editor.registerNodeTransform(LayoutContainerNode, (node) => { diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx index 4549d8a10e8..eff896fcafa 100644 --- a/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ShortcutsPlugin/index.tsx @@ -34,6 +34,7 @@ import { UpdateFontSizeType, } from '../ToolbarPlugin/utils'; import { + isCapitalize, isCenterAlign, isClearFormatting, isDecreaseFontSize, @@ -50,11 +51,13 @@ import { isInsertLink, isJustifyAlign, isLeftAlign, + isLowercase, isOutdent, isRightAlign, isStrikeThrough, isSubscript, isSuperscript, + isUppercase, } from './shortcuts'; export default function ShortcutsPlugin({ @@ -96,6 +99,15 @@ export default function ShortcutsPlugin({ } else if (isStrikeThrough(event)) { event.preventDefault(); editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); + } else if (isLowercase(event)) { + event.preventDefault(); + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'lowercase'); + } else if (isUppercase(event)) { + event.preventDefault(); + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'uppercase'); + } else if (isCapitalize(event)) { + event.preventDefault(); + editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'capitalize'); } else if (isIndent(event)) { event.preventDefault(); editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); diff --git a/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts b/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts index 4a959f9dcac..5ea8514e98a 100644 --- a/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts +++ b/packages/lexical-playground/src/plugins/ShortcutsPlugin/shortcuts.ts @@ -27,6 +27,9 @@ export const SHORTCUTS = Object.freeze({ DECREASE_FONT_SIZE: IS_APPLE ? '⌘+Shift+,' : 'Ctrl+Shift+,', INSERT_CODE_BLOCK: IS_APPLE ? '⌘+Shift+C' : 'Ctrl+Shift+C', STRIKETHROUGH: IS_APPLE ? '⌘+Shift+S' : 'Ctrl+Shift+S', + LOWERCASE: IS_APPLE ? '⌘+Shift+1' : 'Ctrl+Shift+1', + UPPERCASE: IS_APPLE ? '⌘+Shift+2' : 'Ctrl+Shift+2', + CAPITALIZE: IS_APPLE ? '⌘+Shift+3' : 'Ctrl+Shift+3', CENTER_ALIGN: IS_APPLE ? '⌘+Shift+E' : 'Ctrl+Shift+E', JUSTIFY_ALIGN: IS_APPLE ? '⌘+Shift+J' : 'Ctrl+Shift+J', LEFT_ALIGN: IS_APPLE ? '⌘+Shift+L' : 'Ctrl+Shift+L', @@ -117,6 +120,36 @@ export function isFormatQuote(event: KeyboardEvent): boolean { ); } +export function isLowercase(event: KeyboardEvent): boolean { + const {code, shiftKey, altKey, metaKey, ctrlKey} = event; + return ( + (code === 'Numpad1' || code === 'Digit1') && + shiftKey && + !altKey && + controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isUppercase(event: KeyboardEvent): boolean { + const {code, shiftKey, altKey, metaKey, ctrlKey} = event; + return ( + (code === 'Numpad2' || code === 'Digit2') && + shiftKey && + !altKey && + controlOrMeta(metaKey, ctrlKey) + ); +} + +export function isCapitalize(event: KeyboardEvent): boolean { + const {code, shiftKey, altKey, metaKey, ctrlKey} = event; + return ( + (code === 'Numpad3' || code === 'Digit3') && + shiftKey && + !altKey && + controlOrMeta(metaKey, ctrlKey) + ); +} + export function isStrikeThrough(event: KeyboardEvent): boolean { const {code, shiftKey, altKey, metaKey, ctrlKey} = event; return ( diff --git a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx index 2e17272cbf7..7fdae2e20c1 100644 --- a/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableActionMenuPlugin/index.tsx @@ -43,6 +43,7 @@ import { $isTextNode, COMMAND_PRIORITY_CRITICAL, getDOMSelection, + isDOMNode, SELECTION_CHANGE_COMMAND, } from 'lexical'; import * as React from 'react'; @@ -219,8 +220,9 @@ function TableActionMenu({ if ( dropDownRef.current != null && contextRef.current != null && - !dropDownRef.current.contains(event.target as Node) && - !contextRef.current.contains(event.target as Node) + isDOMNode(event.target) && + !dropDownRef.current.contains(event.target) && + !contextRef.current.contains(event.target) ) { setIsMenuOpen(false); } @@ -309,11 +311,13 @@ function TableActionMenu({ const insertTableRowAtSelection = useCallback( (shouldInsertAfter: boolean) => { editor.update(() => { - $insertTableRow__EXPERIMENTAL(shouldInsertAfter); + for (let i = 0; i < selectionCounts.rows; i++) { + $insertTableRow__EXPERIMENTAL(shouldInsertAfter); + } onClose(); }); }, - [editor, onClose], + [editor, onClose, selectionCounts.rows], ); const insertTableColumnAtSelection = useCallback( diff --git a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx index 23f8d1935ab..72f6fe694a8 100644 --- a/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx +++ b/packages/lexical-playground/src/plugins/TableCellResizer/index.tsx @@ -23,7 +23,7 @@ import { TableNode, } from '@lexical/table'; import {calculateZoomLevel} from '@lexical/utils'; -import {$getNearestNodeFromDOMNode} from 'lexical'; +import {$getNearestNodeFromDOMNode, isHTMLElement} from 'lexical'; import * as React from 'react'; import { MouseEventHandler, @@ -89,6 +89,9 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { useEffect(() => { const onMouseMove = (event: MouseEvent) => { const target = event.target; + if (!isHTMLElement(target)) { + return; + } if (draggingDirection) { updateMouseCurrentPos({ @@ -98,13 +101,13 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element { return; } updateIsMouseDown(isMouseDownOnEvent(event)); - if (resizerRef.current && resizerRef.current.contains(target as Node)) { + if (resizerRef.current && resizerRef.current.contains(target)) { return; } if (targetRef.current !== target) { - targetRef.current = target as HTMLElement; - const cell = getDOMCellFromTarget(target as HTMLElement); + targetRef.current = target; + const cell = getDOMCellFromTarget(target); if (cell && activeCell !== cell) { editor.getEditorState().read( diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx index 2116d21179a..00b1169f06e 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx @@ -22,7 +22,7 @@ import { TableRowNode, } from '@lexical/table'; import {$findMatchingParent, mergeRegister} from '@lexical/utils'; -import {$getNearestNodeFromDOMNode, NodeKey} from 'lexical'; +import {$getNearestNodeFromDOMNode, isHTMLElement, NodeKey} from 'lexical'; import {useEffect, useMemo, useRef, useState} from 'react'; import * as React from 'react'; import {createPortal} from 'react-dom'; @@ -257,7 +257,7 @@ function getMouseInfo(event: MouseEvent): { } { const target = event.target; - if (target && target instanceof HTMLElement) { + if (isHTMLElement(target)) { const tableDOMNode = target.closest( 'td.PlaygroundEditorTheme__tableCell, th.PlaygroundEditorTheme__tableCell', ); diff --git a/packages/lexical-playground/src/plugins/TablePlugin.tsx b/packages/lexical-playground/src/plugins/TablePlugin.tsx index e53ef957546..795c784335e 100644 --- a/packages/lexical-playground/src/plugins/TablePlugin.tsx +++ b/packages/lexical-playground/src/plugins/TablePlugin.tsx @@ -8,22 +8,13 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { - $createTableNodeWithDimensions, INSERT_TABLE_COMMAND, + TableCellNode, TableNode, + TableRowNode, } from '@lexical/table'; -import { - $insertNodes, - COMMAND_PRIORITY_EDITOR, - createCommand, - EditorThemeClasses, - Klass, - LexicalCommand, - LexicalEditor, - LexicalNode, -} from 'lexical'; +import {EditorThemeClasses, Klass, LexicalEditor, LexicalNode} from 'lexical'; import {createContext, useContext, useEffect, useMemo, useState} from 'react'; -import * as React from 'react'; import invariant from 'shared/invariant'; import Button from '../ui/Button'; @@ -53,9 +44,6 @@ export type CellEditorConfig = Readonly<{ theme?: EditorThemeClasses; }>; -export const INSERT_NEW_TABLE_COMMAND: LexicalCommand = - createCommand('INSERT_NEW_TABLE_COMMAND'); - export const CellContext = createContext({ cellEditorConfig: null, cellEditorPlugins: null, @@ -155,28 +143,16 @@ export function TablePlugin({ }): JSX.Element | null { const [editor] = useLexicalComposerContext(); const cellContext = useContext(CellContext); - useEffect(() => { - if (!editor.hasNodes([TableNode])) { - invariant(false, 'TablePlugin: TableNode is not registered on editor'); + if (!editor.hasNodes([TableNode, TableRowNode, TableCellNode])) { + invariant( + false, + 'TablePlugin: TableNode, TableRowNode, or TableCellNode is not registered on editor', + ); } - + }, [editor]); + useEffect(() => { cellContext.set(cellEditorConfig, children); - - return editor.registerCommand( - INSERT_NEW_TABLE_COMMAND, - ({columns, rows, includeHeaders}) => { - const tableNode = $createTableNodeWithDimensions( - Number(rows), - Number(columns), - includeHeaders, - ); - $insertNodes([tableNode]); - return true; - }, - COMMAND_PRIORITY_EDITOR, - ); - }, [cellContext, cellEditorConfig, children, editor]); - + }, [cellContext, cellEditorConfig, children]); return null; } diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx index ed5da202bca..1dd6dc066d4 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/index.tsx @@ -615,6 +615,9 @@ export default function ToolbarPlugin({ 'fontSize', $getSelectionStyleValueForProperty(selection, 'font-size', '15px'), ); + updateToolbarState('isLowercase', selection.hasFormat('lowercase')); + updateToolbarState('isUppercase', selection.hasFormat('uppercase')); + updateToolbarState('isCapitalize', selection.hasFormat('capitalize')); } }, [activeEditor, editor, updateToolbarState]); @@ -888,6 +891,51 @@ export default function ToolbarPlugin({ buttonLabel="" buttonAriaLabel="Formatting options for additional text styles" buttonIconClassName="icon dropdown-more"> + { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'lowercase'); + }} + className={ + 'item wide ' + dropDownActiveClass(toolbarState.isLowercase) + } + title="Lowercase" + aria-label="Format text to lowercase"> +
+ + Lowercase +
+ {SHORTCUTS.LOWERCASE} +
+ { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'uppercase'); + }} + className={ + 'item wide ' + dropDownActiveClass(toolbarState.isUppercase) + } + title="Uppercase" + aria-label="Format text to uppercase"> +
+ + Uppercase +
+ {SHORTCUTS.UPPERCASE} +
+ { + activeEditor.dispatchCommand(FORMAT_TEXT_COMMAND, 'capitalize'); + }} + className={ + 'item wide ' + dropDownActiveClass(toolbarState.isCapitalize) + } + title="Capitalize" + aria-label="Format text to capitalize"> +
+ + Capitalize +
+ {SHORTCUTS.CAPITALIZE} +
{ activeEditor.dispatchCommand( diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css index 527085b7539..b5306f0b691 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.css @@ -57,12 +57,56 @@ .PlaygroundEditorTheme__textUnderline { text-decoration: underline; } + .PlaygroundEditorTheme__textStrikethrough { text-decoration: line-through; } + .PlaygroundEditorTheme__textUnderlineStrikethrough { text-decoration: underline line-through; } + +.PlaygroundEditorTheme__tabNode { + position: relative; + text-decoration: none; +} + +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderline::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: 0.15em; + border-bottom: 0.1em solid currentColor; +} + +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textStrikethrough::before { + content: ''; + position: absolute; + left: 0; + right: 0; + top: 0.69em; + border-top: 0.1em solid currentColor; +} + +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::before, +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::after { + content: ''; + position: absolute; + left: 0; + right: 0; +} + +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::before { + top: 0.69em; + border-top: 0.1em solid currentColor; +} + +.PlaygroundEditorTheme__tabNode.PlaygroundEditorTheme__textUnderlineStrikethrough::after { + bottom: 0.05em; + border-bottom: 0.1em solid currentColor; +} + .PlaygroundEditorTheme__textSubscript { font-size: 0.8em; vertical-align: sub !important; @@ -77,6 +121,15 @@ font-family: Menlo, Consolas, Monaco, monospace; font-size: 94%; } +.PlaygroundEditorTheme__textLowercase { + text-transform: lowercase; +} +.PlaygroundEditorTheme__textUppercase { + text-transform: uppercase; +} +.PlaygroundEditorTheme__textCapitalize { + text-transform: capitalize; +} .PlaygroundEditorTheme__hashtag { background-color: rgba(88, 144, 255, 0.15); border-bottom: 1px solid rgba(88, 144, 255, 0.3); @@ -89,6 +142,25 @@ text-decoration: underline; cursor: pointer; } +.PlaygroundEditorTheme__blockCursor { + display: block; + pointer-events: none; + position: absolute; +} +.PlaygroundEditorTheme__blockCursor:after { + content: ''; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: CursorBlink 1.1s steps(2, start) infinite; +} +@keyframes CursorBlink { + to { + visibility: hidden; + } +} .PlaygroundEditorTheme__code { background-color: rgb(240, 242, 245); font-family: Menlo, Consolas, Monaco, monospace; @@ -150,6 +222,14 @@ padding: 6px 8px; position: relative; outline: none; + overflow: auto; +} +/* + A firefox workaround to allow scrolling of overflowing table cell + ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1904159 +*/ +.PlaygroundEditorTheme__tableCell > * { + overflow: inherit; } .PlaygroundEditorTheme__tableCellResizer { position: absolute; diff --git a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts index 0b45916782b..e7c6a4aab7e 100644 --- a/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts +++ b/packages/lexical-playground/src/themes/PlaygroundEditorTheme.ts @@ -91,6 +91,7 @@ const theme: EditorThemeClasses = { quote: 'PlaygroundEditorTheme__quote', rtl: 'PlaygroundEditorTheme__rtl', specialText: 'PlaygroundEditorTheme__specialText', + tab: 'PlaygroundEditorTheme__tabNode', table: 'PlaygroundEditorTheme__table', tableCell: 'PlaygroundEditorTheme__tableCell', tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton', @@ -105,13 +106,16 @@ const theme: EditorThemeClasses = { tableSelection: 'PlaygroundEditorTheme__tableSelection', text: { bold: 'PlaygroundEditorTheme__textBold', + capitalize: 'PlaygroundEditorTheme__textCapitalize', code: 'PlaygroundEditorTheme__textCode', italic: 'PlaygroundEditorTheme__textItalic', + lowercase: 'PlaygroundEditorTheme__textLowercase', strikethrough: 'PlaygroundEditorTheme__textStrikethrough', subscript: 'PlaygroundEditorTheme__textSubscript', superscript: 'PlaygroundEditorTheme__textSuperscript', underline: 'PlaygroundEditorTheme__textUnderline', underlineStrikethrough: 'PlaygroundEditorTheme__textUnderlineStrikethrough', + uppercase: 'PlaygroundEditorTheme__textUppercase', }, }; diff --git a/packages/lexical-playground/src/ui/DropDown.tsx b/packages/lexical-playground/src/ui/DropDown.tsx index 012d4674b9e..19d3065df0e 100644 --- a/packages/lexical-playground/src/ui/DropDown.tsx +++ b/packages/lexical-playground/src/ui/DropDown.tsx @@ -6,6 +6,7 @@ * */ +import {isDOMNode} from 'lexical'; import * as React from 'react'; import { ReactNode, @@ -189,15 +190,15 @@ export default function DropDown({ if (button !== null && showDropDown) { const handle = (event: MouseEvent) => { const target = event.target; + if (!isDOMNode(target)) { + return; + } if (stopCloseOnClickSelf) { - if ( - dropDownRef.current && - dropDownRef.current.contains(target as Node) - ) { + if (dropDownRef.current && dropDownRef.current.contains(target)) { return; } } - if (!button.contains(target as Node)) { + if (!button.contains(target)) { setShowDropDown(false); } }; diff --git a/packages/lexical-playground/src/ui/EquationEditor.tsx b/packages/lexical-playground/src/ui/EquationEditor.tsx index d8f9aec527c..ce512aed010 100644 --- a/packages/lexical-playground/src/ui/EquationEditor.tsx +++ b/packages/lexical-playground/src/ui/EquationEditor.tsx @@ -10,7 +10,7 @@ import type {Ref, RefObject} from 'react'; import './EquationEditor.css'; -import * as React from 'react'; +import {isHTMLElement} from 'lexical'; import {ChangeEvent, forwardRef} from 'react'; type BaseEquationEditorProps = { @@ -27,7 +27,7 @@ function EquationEditor( setEquation((event.target as HTMLInputElement).value); }; - return inline && forwardedRef instanceof HTMLInputElement ? ( + return inline && isHTMLElement(forwardedRef) ? ( $ void) { const target = event.target; - if (target === null || !isHTMLElement(target)) { + if (!isHTMLElement(target)) { return; } @@ -173,7 +173,6 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) { const firstChild = target.firstChild; if ( - firstChild != null && isHTMLElement(firstChild) && (firstChild.tagName === 'UL' || firstChild.tagName === 'OL') ) { @@ -200,7 +199,7 @@ function handleCheckItemEvent(event: PointerEvent, callback: () => void) { function handleClick(event: Event) { handleCheckItemEvent(event as PointerEvent, () => { - if (event.target instanceof HTMLElement) { + if (isHTMLElement(event.target)) { const domNode = event.target; const editor = getNearestEditorFromDOMNode(domNode); @@ -226,9 +225,9 @@ function handlePointerDown(event: PointerEvent) { } function getActiveCheckListItem(): HTMLElement | null { - const activeElement = document.activeElement as HTMLElement; + const activeElement = document.activeElement; - return activeElement != null && + return isHTMLElement(activeElement) && activeElement.tagName === 'LI' && activeElement.parentNode != null && // @ts-ignore internal field diff --git a/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx b/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx index 3c5e56c36e2..dd76a0b30a5 100644 --- a/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx +++ b/packages/lexical-react/src/LexicalClickableLinkPlugin.tsx @@ -15,6 +15,7 @@ import { $isElementNode, $isRangeSelection, getNearestEditorFromDOMNode, + isDOMNode, } from 'lexical'; import {useEffect} from 'react'; @@ -44,7 +45,7 @@ export function ClickableLinkPlugin({ useEffect(() => { const onClick = (event: MouseEvent) => { const target = event.target; - if (!(target instanceof Node)) { + if (!isDOMNode(target)) { return; } const nearestEditor = getNearestEditorFromDOMNode(target); diff --git a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx index c105d48b8f1..7f1676f3cf9 100644 --- a/packages/lexical-react/src/LexicalContextMenuPlugin.tsx +++ b/packages/lexical-react/src/LexicalContextMenuPlugin.tsx @@ -12,6 +12,7 @@ import {calculateZoomLevel} from '@lexical/utils'; import { COMMAND_PRIORITY_LOW, CommandListenerPriority, + isDOMNode, LexicalNode, } from 'lexical'; import { @@ -122,7 +123,8 @@ export function LexicalContextMenuPlugin({ resolution !== null && menuRef.current != null && event.target != null && - !menuRef.current.contains(event.target as Node) + isDOMNode(event.target) && + !menuRef.current.contains(event.target) ) { closeNodeMenu(); } diff --git a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx index d37bd69f3c9..be695b76b23 100644 --- a/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx +++ b/packages/lexical-react/src/LexicalDraggableBlockPlugin.tsx @@ -191,9 +191,15 @@ function setMenuPosition( const floatingElemRect = floatingElem.getBoundingClientRect(); const anchorElementRect = anchorElem.getBoundingClientRect(); + // top left + let targetCalculateHeight: number = parseInt(targetStyle.lineHeight, 10); + if (isNaN(targetCalculateHeight)) { + // middle + targetCalculateHeight = targetRect.bottom - targetRect.top; + } const top = targetRect.top + - (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - + (targetCalculateHeight - floatingElemRect.height) / 2 - anchorElementRect.top; const left = SPACE; @@ -271,12 +277,12 @@ function useDraggableBlockMenu( useEffect(() => { function onMouseMove(event: MouseEvent) { const target = event.target; - if (target != null && !isHTMLElement(target)) { + if (!isHTMLElement(target)) { setDraggableBlockElem(null); return; } - if (target != null && isOnMenu(target as HTMLElement)) { + if (isOnMenu(target as HTMLElement)) { return; } @@ -318,7 +324,7 @@ function useDraggableBlockMenu( return false; } const {pageY, target} = event; - if (target != null && !isHTMLElement(target)) { + if (!isHTMLElement(target)) { return false; } const targetBlockElem = getBlockElement(anchorElem, editor, event, true); @@ -352,7 +358,7 @@ function useDraggableBlockMenu( if (!draggedNode) { return false; } - if (target != null && !isHTMLElement(target)) { + if (!isHTMLElement(target)) { return false; } const targetBlockElem = getBlockElement(anchorElem, editor, event, true); diff --git a/packages/lexical-react/src/LexicalTablePlugin.ts b/packages/lexical-react/src/LexicalTablePlugin.ts index a5c43d17c65..7d91b0b576c 100644 --- a/packages/lexical-react/src/LexicalTablePlugin.ts +++ b/packages/lexical-react/src/LexicalTablePlugin.ts @@ -6,43 +6,15 @@ * */ -import type { - HTMLTableElementWithWithTableSelectionState, - InsertTableCommandPayload, - TableObserver, -} from '@lexical/table'; -import type {NodeKey} from 'lexical'; - import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import { - $computeTableMap, - $computeTableMapSkipCellCheck, - $createTableCellNode, - $createTableNodeWithDimensions, - $getNodeTriplet, - $getTableAndElementByKey, - $isTableCellNode, - $isTableRowNode, - applyTableHandlers, - getTableElement, - INSERT_TABLE_COMMAND, + registerTableCellUnmergeTransform, + registerTablePlugin, + registerTableSelectionObserver, setScrollableTablesActive, TableCellNode, - TableNode, - TableRowNode, } from '@lexical/table'; -import { - $insertFirst, - $insertNodeToNearestRoot, - mergeRegister, -} from '@lexical/utils'; -import { - $createParagraphNode, - $isTextNode, - COMMAND_PRIORITY_EDITOR, -} from 'lexical'; import {useEffect} from 'react'; -import invariant from 'shared/invariant'; export interface TablePluginProps { /** @@ -82,181 +54,18 @@ export function TablePlugin({ setScrollableTablesActive(editor, hasHorizontalScroll); }, [editor, hasHorizontalScroll]); - useEffect(() => { - if (!editor.hasNodes([TableNode, TableCellNode, TableRowNode])) { - invariant( - false, - 'TablePlugin: TableNode, TableCellNode or TableRowNode not registered on editor', - ); - } - - return mergeRegister( - editor.registerCommand( - INSERT_TABLE_COMMAND, - ({columns, rows, includeHeaders}) => { - const tableNode = $createTableNodeWithDimensions( - Number(rows), - Number(columns), - includeHeaders, - ); - $insertNodeToNearestRoot(tableNode); - - const firstDescendant = tableNode.getFirstDescendant(); - if ($isTextNode(firstDescendant)) { - firstDescendant.select(); - } - - return true; - }, - COMMAND_PRIORITY_EDITOR, - ), - editor.registerNodeTransform(TableNode, (node) => { - const [gridMap] = $computeTableMapSkipCellCheck(node, null, null); - const maxRowLength = gridMap.reduce((curLength, row) => { - return Math.max(curLength, row.length); - }, 0); - const rowNodes = node.getChildren(); - for (let i = 0; i < gridMap.length; ++i) { - const rowNode = rowNodes[i]; - if (!rowNode) { - continue; - } - const rowLength = gridMap[i].reduce( - (acc, cell) => (cell ? 1 + acc : acc), - 0, - ); - if (rowLength === maxRowLength) { - continue; - } - for (let j = rowLength; j < maxRowLength; ++j) { - // TODO: inherit header state from another header or body - const newCell = $createTableCellNode(0); - newCell.append($createParagraphNode()); - (rowNode as TableRowNode).append(newCell); - } - } - }), - ); - }, [editor]); + useEffect(() => registerTablePlugin(editor), [editor]); - useEffect(() => { - const tableSelections = new Map< - NodeKey, - [TableObserver, HTMLTableElementWithWithTableSelectionState] - >(); - - const initializeTableNode = ( - tableNode: TableNode, - nodeKey: NodeKey, - dom: HTMLElement, - ) => { - const tableElement = getTableElement(tableNode, dom); - const tableSelection = applyTableHandlers( - tableNode, - tableElement, - editor, - hasTabHandler, - ); - tableSelections.set(nodeKey, [tableSelection, tableElement]); - }; - - const unregisterMutationListener = editor.registerMutationListener( - TableNode, - (nodeMutations) => { - editor.getEditorState().read( - () => { - for (const [nodeKey, mutation] of nodeMutations) { - const tableSelection = tableSelections.get(nodeKey); - if (mutation === 'created' || mutation === 'updated') { - const {tableNode, tableElement} = - $getTableAndElementByKey(nodeKey); - if (tableSelection === undefined) { - initializeTableNode(tableNode, nodeKey, tableElement); - } else if (tableElement !== tableSelection[1]) { - // The update created a new DOM node, destroy the existing TableObserver - tableSelection[0].removeListeners(); - tableSelections.delete(nodeKey); - initializeTableNode(tableNode, nodeKey, tableElement); - } - } else if (mutation === 'destroyed') { - if (tableSelection !== undefined) { - tableSelection[0].removeListeners(); - tableSelections.delete(nodeKey); - } - } - } - }, - {editor}, - ); - }, - {skipInitialization: false}, - ); - - return () => { - unregisterMutationListener(); - // Hook might be called multiple times so cleaning up tables listeners as well, - // as it'll be reinitialized during recurring call - for (const [, [tableSelection]] of tableSelections) { - tableSelection.removeListeners(); - } - }; - }, [editor, hasTabHandler]); + useEffect( + () => registerTableSelectionObserver(editor, hasTabHandler), + [editor, hasTabHandler], + ); // Unmerge cells when the feature isn't enabled useEffect(() => { - if (hasCellMerge) { - return; + if (!hasCellMerge) { + return registerTableCellUnmergeTransform(editor); } - return editor.registerNodeTransform(TableCellNode, (node) => { - if (node.getColSpan() > 1 || node.getRowSpan() > 1) { - // When we have rowSpan we have to map the entire Table to understand where the new Cells - // fit best; let's analyze all Cells at once to save us from further transform iterations - const [, , gridNode] = $getNodeTriplet(node); - const [gridMap] = $computeTableMap(gridNode, node, node); - // TODO this function expects Tables to be normalized. Look into this once it exists - const rowsCount = gridMap.length; - const columnsCount = gridMap[0].length; - let row = gridNode.getFirstChild(); - invariant( - $isTableRowNode(row), - 'Expected TableNode first child to be a RowNode', - ); - const unmerged = []; - for (let i = 0; i < rowsCount; i++) { - if (i !== 0) { - row = row.getNextSibling(); - invariant( - $isTableRowNode(row), - 'Expected TableNode first child to be a RowNode', - ); - } - let lastRowCell: null | TableCellNode = null; - for (let j = 0; j < columnsCount; j++) { - const cellMap = gridMap[i][j]; - const cell = cellMap.cell; - if (cellMap.startRow === i && cellMap.startColumn === j) { - lastRowCell = cell; - unmerged.push(cell); - } else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) { - invariant( - $isTableCellNode(cell), - 'Expected TableNode cell to be a TableCellNode', - ); - const newCell = $createTableCellNode(cell.__headerState); - if (lastRowCell !== null) { - lastRowCell.insertAfter(newCell); - } else { - $insertFirst(row, newCell); - } - } - } - } - for (const cell of unmerged) { - cell.setColSpan(1); - cell.setRowSpan(1); - } - } - }); }, [editor, hasCellMerge]); // Remove cell background color when feature is disabled diff --git a/packages/lexical-react/src/shared/useCharacterLimit.ts b/packages/lexical-react/src/shared/useCharacterLimit.ts index 8e1e4f813c0..75d8e58040b 100644 --- a/packages/lexical-react/src/shared/useCharacterLimit.ts +++ b/packages/lexical-react/src/shared/useCharacterLimit.ts @@ -14,7 +14,7 @@ import { OverflowNode, } from '@lexical/overflow'; import {$rootTextContent} from '@lexical/text'; -import {$dfs, mergeRegister} from '@lexical/utils'; +import {$dfs, $unwrapNode, mergeRegister} from '@lexical/utils'; import { $getSelection, $isElementNode, @@ -254,18 +254,6 @@ function $wrapNode(node: LexicalNode): OverflowNode { return overflowNode; } -function $unwrapNode(node: OverflowNode): LexicalNode | null { - const children = node.getChildren(); - const childrenLength = children.length; - - for (let i = 0; i < childrenLength; i++) { - node.insertBefore(children[i]); - } - - node.remove(); - return childrenLength > 0 ? children[childrenLength - 1] : null; -} - export function $mergePrevious(overflowNode: OverflowNode): void { const previousNode = overflowNode.getPreviousSibling(); diff --git a/packages/lexical-react/src/useLexicalNodeSelection.ts b/packages/lexical-react/src/useLexicalNodeSelection.ts index 2b6c0621d4c..1ee2a9adf89 100644 --- a/packages/lexical-react/src/useLexicalNodeSelection.ts +++ b/packages/lexical-react/src/useLexicalNodeSelection.ts @@ -18,23 +18,48 @@ import { } from 'lexical'; import {useCallback, useEffect, useState} from 'react'; +/** + * A helper function to determine if a specific node is selected in a Lexical editor. + * + * @param {LexicalEditor} editor - The LexicalEditor instance. + * @param {NodeKey} key - The key of the node to check. + * @returns {boolean} Whether the node is selected. + */ + function isNodeSelected(editor: LexicalEditor, key: NodeKey): boolean { return editor.getEditorState().read(() => { const node = $getNodeByKey(key); if (node === null) { - return false; + return false; // Node doesn't exist, so it's not selected. } - return node.isSelected(); + return node.isSelected(); // Check if the node is selected. }); } +/** + * A custom hook to manage the selection state of a specific node in a Lexical editor. + * + * This hook provides utilities to: + * - Check if a node is selected. + * - Update its selection state. + * - Clear the selection. + * + * @param {NodeKey} key - The key of the node to track selection for. + * @returns {[boolean, (selected: boolean) => void, () => void]} A tuple containing: + * - `isSelected` (boolean): Whether the node is currently selected. + * - `setSelected` (function): A function to set the selection state of the node. + * - `clearSelected` (function): A function to clear the selection of the node. + * + */ + export function useLexicalNodeSelection( key: NodeKey, -): [boolean, (arg0: boolean) => void, () => void] { +): [boolean, (selected: boolean) => void, () => void] { const [editor] = useLexicalComposerContext(); + // State to track whether the node is currently selected. const [isSelected, setIsSelected] = useState(() => isNodeSelected(editor, key), ); @@ -48,7 +73,7 @@ export function useLexicalNodeSelection( }); return () => { - isMounted = false; + isMounted = false; // Prevent updates after component unmount. unregister(); }; }, [editor, key]); @@ -62,6 +87,7 @@ export function useLexicalNodeSelection( selection = $createNodeSelection(); $setSelection(selection); } + if ($isNodeSelection(selection)) { if (selected) { selection.add(key); diff --git a/packages/lexical-rich-text/flow/LexicalRichText.js.flow b/packages/lexical-rich-text/flow/LexicalRichText.js.flow index 0751c17fc5f..9b7bbb0c803 100644 --- a/packages/lexical-rich-text/flow/LexicalRichText.js.flow +++ b/packages/lexical-rich-text/flow/LexicalRichText.js.flow @@ -25,7 +25,6 @@ declare export class QuoteNode extends ElementNode { static clone(node: QuoteNode): QuoteNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, @@ -44,7 +43,6 @@ declare export class HeadingNode extends ElementNode { constructor(tag: HeadingTagType, key?: NodeKey): void; getTag(): HeadingTagType; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean; static importDOM(): DOMConversionMap | null; insertNewAfter( selection: RangeSelection, diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index cec5da17fa7..7e39f5631dd 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -79,6 +79,7 @@ import { INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, INSERT_TAB_COMMAND, + isDOMNode, isSelectionCapturedInDecoratorInput, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, @@ -88,6 +89,8 @@ import { KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, + KEY_SPACE_COMMAND, + KEY_TAB_COMMAND, OUTDENT_CONTENT_COMMAND, PASTE_COMMAND, REMOVE_TEXT_COMMAND, @@ -136,7 +139,7 @@ export class QuoteNode extends ElementNode { addClassNamesToElement(element, config.theme.quote); return element; } - updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } @@ -152,7 +155,7 @@ export class QuoteNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); - if (element && isHTMLElement(element)) { + if (isHTMLElement(element)) { if (this.isEmpty()) { element.append(document.createElement('br')); } @@ -257,7 +260,7 @@ export class HeadingNode extends ElementNode { return element; } - updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement): boolean { return false; } @@ -318,7 +321,7 @@ export class HeadingNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); - if (element && isHTMLElement(element)) { + if (isHTMLElement(element)) { if (this.isEmpty()) { element.append(document.createElement('br')); } @@ -549,6 +552,19 @@ function $isSelectionAtEndOfRoot(selection: RangeSelection) { return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize(); } +/** + * Resets the capitalization of the selection to default. + * Called when the user presses space, tab, or enter key. + * @param selection The selection to reset the capitalization of. + */ +function $resetCapitalization(selection: RangeSelection): void { + for (const format of ['lowercase', 'uppercase', 'capitalize'] as const) { + if (selection.hasFormat(format)) { + selection.toggleFormat(format); + } + } +} + export function registerRichText(editor: LexicalEditor): () => void { const removeListener = mergeRegister( editor.registerCommand( @@ -909,6 +925,9 @@ export function registerRichText(editor: LexicalEditor): () => void { if (!$isRangeSelection(selection)) { return false; } + + $resetCapitalization(selection); + if (event !== null) { // If we have beforeinput, then we can avoid blocking // the default behavior. This ensures that the iOS can @@ -1060,7 +1079,10 @@ export function registerRichText(editor: LexicalEditor): () => void { } // if inputs then paste within the input ignore creating a new node on paste event - if (isSelectionCapturedInDecoratorInput(event.target as Node)) { + if ( + isDOMNode(event.target) && + isSelectionCapturedInDecoratorInput(event.target) + ) { return false; } @@ -1074,6 +1096,32 @@ export function registerRichText(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_EDITOR, ), + editor.registerCommand( + KEY_SPACE_COMMAND, + (_) => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $resetCapitalization(selection); + } + + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + KEY_TAB_COMMAND, + (_) => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $resetCapitalization(selection); + } + + return false; + }, + COMMAND_PRIORITY_EDITOR, + ), ); return removeListener; } diff --git a/packages/lexical-selection/src/index.ts b/packages/lexical-selection/src/index.ts index 8f83b573279..2fd6becf09b 100644 --- a/packages/lexical-selection/src/index.ts +++ b/packages/lexical-selection/src/index.ts @@ -19,7 +19,6 @@ import { $isParentElementRTL, $moveCaretSelection, $moveCharacter, - $selectAll, $setBlocksType, $shouldOverrideDefaultCharacterSelection, $wrapNodes, @@ -33,7 +32,9 @@ import { export { /** @deprecated moved to the lexical package */ $cloneWithProperties, + /** @deprecated moved to the lexical package */ $selectAll, } from 'lexical'; + export { $addNodeStyle, $forEachSelectedTextNode, @@ -50,7 +51,6 @@ export { $isParentElementRTL, $moveCaretSelection, $moveCharacter, - $selectAll, $setBlocksType, $shouldOverrideDefaultCharacterSelection, $wrapNodes, diff --git a/packages/lexical-selection/src/range-selection.ts b/packages/lexical-selection/src/range-selection.ts index e92a81b6188..15b4e66c5c7 100644 --- a/packages/lexical-selection/src/range-selection.ts +++ b/packages/lexical-selection/src/range-selection.ts @@ -452,41 +452,6 @@ export function $moveCharacter( ); } -/** - * Expands the current Selection to cover all of the content in the editor. - * @param selection - The current selection. - */ -export function $selectAll(selection: RangeSelection): void { - const anchor = selection.anchor; - const focus = selection.focus; - const anchorNode = anchor.getNode(); - const topParent = anchorNode.getTopLevelElementOrThrow(); - const root = topParent.getParentOrThrow(); - let firstNode = root.getFirstDescendant(); - let lastNode = root.getLastDescendant(); - let firstType: 'element' | 'text' = 'element'; - let lastType: 'element' | 'text' = 'element'; - let lastOffset = 0; - - if ($isTextNode(firstNode)) { - firstType = 'text'; - } else if (!$isElementNode(firstNode) && firstNode !== null) { - firstNode = firstNode.getParentOrThrow(); - } - - if ($isTextNode(lastNode)) { - lastType = 'text'; - lastOffset = lastNode.getTextContentSize(); - } else if (!$isElementNode(lastNode) && lastNode !== null) { - lastNode = lastNode.getParentOrThrow(); - } - - if (firstNode && lastNode) { - anchor.set(firstNode.getKey(), 0, firstType); - focus.set(lastNode.getKey(), lastOffset, lastType); - } -} - /** * Returns the current value of a CSS property for Nodes, if set. If not set, it returns the defaultValue. * @param node - The node whose style value to get. diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 2674a125f50..075314baf93 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -52,7 +52,6 @@ declare export class TableCellNode extends ElementNode { key?: NodeKey, ): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableCellNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, ): null | ParagraphNode | TableCellNode; @@ -70,12 +69,11 @@ declare export class TableCellNode extends ElementNode { setBackgroundColor(newBackgroundColor: null | string): TableCellNode; toggleHeaderStyle(headerState: TableCellHeaderState): TableCellNode; hasHeader(): boolean; - updateDOM(prevNode: TableCellNode): boolean; collapseAtStart(): true; canBeEmpty(): false; } declare export function $createTableCellNode( - headerState: TableCellHeaderState, + headerState?: TableCellHeaderState, colSpan?: number, width?: ?number, ): TableCellNode; @@ -99,7 +97,6 @@ declare export class TableNode extends ElementNode { static clone(node: TableNode): TableNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableNode, dom: HTMLElement): boolean; insertNewAfter(selection: RangeSelection): null | ParagraphNode | TableNode; collapseAtStart(): true; getCordsFromCellNode( @@ -126,7 +123,6 @@ declare export class TableRowNode extends ElementNode { static clone(node: TableRowNode): TableRowNode; constructor(height?: ?number, key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: TableRowNode, dom: HTMLElement): boolean; setHeight(height: number): ?number; getHeight(): ?number; insertNewAfter( @@ -350,4 +346,14 @@ export type InsertTableCommandPayload = $ReadOnly<{ includeHeaders?: InsertTableCommandPayloadHeaders; }>; -declare export var INSERT_TABLE_COMMAND: LexicalCommand; \ No newline at end of file +declare export var INSERT_TABLE_COMMAND: LexicalCommand; + +/** + * LexicalTablePluginHelpers + */ + +declare export function registerTableCellUnmergeTransform(editor: LexicalEditor): () => void; + +declare export function registerTablePlugin(editor: LexicalEditor): () => void; + +declare export function registerTableSelectionObserver(editor: LexicalEditor, hasTabHandler?: boolean): () => void; diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index 2b00b8dfb6e..396fc6564d7 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -151,7 +151,7 @@ export class TableCellNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const output = super.exportDOM(editor); - if (output.element && isHTMLElement(output.element)) { + if (isHTMLElement(output.element)) { const element = output.element as HTMLTableCellElement; element.setAttribute( 'data-temporary-table-cell-lexical-key', @@ -265,7 +265,7 @@ export class TableCellNode extends ElementNode { return this.getLatest().__headerState !== TableCellHeaderStates.NO_STATUS; } - updateDOM(prevNode: TableCellNode): boolean { + updateDOM(prevNode: this): boolean { return ( prevNode.__headerState !== this.__headerState || prevNode.__width !== this.__width || @@ -366,7 +366,7 @@ export function $convertTableCellNodeElement( } export function $createTableCellNode( - headerState: TableCellHeaderState, + headerState: TableCellHeaderState = TableCellHeaderStates.NO_STATUS, colSpan = 1, width?: number, ): TableCellNode { diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 4a4a2c970fa..fa7aa3d4d04 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -6,9 +6,8 @@ * */ -import type {TableRowNode} from './LexicalTableRowNode'; - import { + $descendantsMatching, addClassNamesToElement, isHTMLElement, removeClassNamesFromElement, @@ -36,6 +35,7 @@ import invariant from 'shared/invariant'; import {PIXEL_VALUE_REG_EXP} from './constants'; import {$isTableCellNode, type TableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; +import {$isTableRowNode, type TableRowNode} from './LexicalTableRowNode'; import { $getNearestTableCellInTableFromDOMNode, getTable, @@ -225,11 +225,7 @@ export class TableNode extends ElementNode { return tableElement; } - updateDOM( - prevNode: TableNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { if (prevNode.__rowStriping !== this.__rowStriping) { setRowStriping(dom, config, this.__rowStriping); } @@ -245,14 +241,10 @@ export class TableNode extends ElementNode { if (superExport.after) { tableElement = superExport.after(tableElement); } - if ( - tableElement && - isHTMLElement(tableElement) && - tableElement.nodeName !== 'TABLE' - ) { + if (isHTMLElement(tableElement) && tableElement.nodeName !== 'TABLE') { tableElement = tableElement.querySelector('table'); } - if (!tableElement || !isHTMLElement(tableElement)) { + if (!isHTMLElement(tableElement)) { return null; } @@ -316,7 +308,7 @@ export class TableNode extends ElementNode { return tableElement; }, element: - element && isHTMLElement(element) && element.nodeName !== 'TABLE' + isHTMLElement(element) && element.nodeName !== 'TABLE' ? element.querySelector('table') : element, }; @@ -487,10 +479,14 @@ export function $convertTableElement( if (colGroup) { let columns: number[] | undefined = []; for (const col of colGroup.querySelectorAll(':scope > col')) { - const width = (col as HTMLElement).style.width; - if (!width || !PIXEL_VALUE_REG_EXP.test(width)) { - columns = undefined; - break; + let width = (col as HTMLElement).style.width || ''; + if (!PIXEL_VALUE_REG_EXP.test(width)) { + // Also support deprecated width attribute for google docs + width = col.getAttribute('width') || ''; + if (!/^\d+$/.test(width)) { + columns = undefined; + break; + } } columns.push(parseFloat(width)); } @@ -498,7 +494,10 @@ export function $convertTableElement( tableNode.setColWidths(columns); } } - return {node: tableNode}; + return { + after: (children) => $descendantsMatching(children, $isTableRowNode), + node: tableNode, + }; } export function $createTableNode(): TableNode { diff --git a/packages/lexical-table/src/LexicalTableObserver.ts b/packages/lexical-table/src/LexicalTableObserver.ts index 059c471b96d..2f76ce0712a 100644 --- a/packages/lexical-table/src/LexicalTableObserver.ts +++ b/packages/lexical-table/src/LexicalTableObserver.ts @@ -462,9 +462,7 @@ export class TableObserver { const selection = $getSelection(); - if (!$isTableSelection(selection)) { - invariant(false, 'Expected grid selection'); - } + invariant($isTableSelection(selection), 'Expected TableSelection'); const selectedNodes = selection.getNodes().filter($isTableCellNode); diff --git a/packages/lexical-table/src/LexicalTablePluginHelpers.ts b/packages/lexical-table/src/LexicalTablePluginHelpers.ts new file mode 100644 index 00000000000..ae7ee4547e2 --- /dev/null +++ b/packages/lexical-table/src/LexicalTablePluginHelpers.ts @@ -0,0 +1,275 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { + $insertFirst, + $insertNodeToNearestRoot, + $unwrapAndFilterDescendants, + mergeRegister, +} from '@lexical/utils'; +import { + $createParagraphNode, + $isTextNode, + COMMAND_PRIORITY_EDITOR, + LexicalEditor, + NodeKey, +} from 'lexical'; +import invariant from 'shared/invariant'; + +import { + $createTableCellNode, + $isTableCellNode, + TableCellNode, +} from './LexicalTableCellNode'; +import { + INSERT_TABLE_COMMAND, + InsertTableCommandPayload, +} from './LexicalTableCommands'; +import {$isTableNode, TableNode} from './LexicalTableNode'; +import {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; +import {$isTableRowNode, TableRowNode} from './LexicalTableRowNode'; +import { + applyTableHandlers, + getTableElement, + HTMLTableElementWithWithTableSelectionState, +} from './LexicalTableSelectionHelpers'; +import { + $computeTableMap, + $computeTableMapSkipCellCheck, + $createTableNodeWithDimensions, + $getNodeTriplet, +} from './LexicalTableUtils'; + +function $insertTableCommandListener({ + rows, + columns, + includeHeaders, +}: InsertTableCommandPayload): boolean { + const tableNode = $createTableNodeWithDimensions( + Number(rows), + Number(columns), + includeHeaders, + ); + $insertNodeToNearestRoot(tableNode); + + const firstDescendant = tableNode.getFirstDescendant(); + if ($isTextNode(firstDescendant)) { + firstDescendant.select(); + } + + return true; +} + +function $tableCellTransform(node: TableCellNode) { + if (!$isTableRowNode(node.getParent())) { + // TableCellNode must be a child of TableRowNode. + node.remove(); + } else if (node.isEmpty()) { + // TableCellNode should never be empty + node.append($createParagraphNode()); + } +} + +function $tableRowTransform(node: TableRowNode) { + if (!$isTableNode(node.getParent())) { + // TableRowNode must be a child of TableNode. + // TODO: Future support of tbody/thead/tfoot may change this + node.remove(); + } else { + $unwrapAndFilterDescendants(node, $isTableCellNode); + } +} + +function $tableTransform(node: TableNode) { + // TableRowNode is the only valid child for TableNode + // TODO: Future support of tbody/thead/tfoot/caption may change this + $unwrapAndFilterDescendants(node, $isTableRowNode); + + const [gridMap] = $computeTableMapSkipCellCheck(node, null, null); + const maxRowLength = gridMap.reduce((curLength, row) => { + return Math.max(curLength, row.length); + }, 0); + const rowNodes = node.getChildren(); + for (let i = 0; i < gridMap.length; ++i) { + const rowNode = rowNodes[i]; + if (!rowNode) { + continue; + } + invariant( + $isTableRowNode(rowNode), + 'TablePlugin: Expecting all children of TableNode to be TableRowNode, found %s (type %s)', + rowNode.constructor.name, + rowNode.getType(), + ); + const rowLength = gridMap[i].reduce( + (acc, cell) => (cell ? 1 + acc : acc), + 0, + ); + if (rowLength === maxRowLength) { + continue; + } + for (let j = rowLength; j < maxRowLength; ++j) { + // TODO: inherit header state from another header or body + const newCell = $createTableCellNode(); + newCell.append($createParagraphNode()); + rowNode.append(newCell); + } + } +} + +/** + * Register a transform to ensure that all TableCellNode have a colSpan and rowSpan of 1. + * This should only be registered when you do not want to support merged cells. + * + * @param editor The editor + * @returns An unregister callback + */ +export function registerTableCellUnmergeTransform( + editor: LexicalEditor, +): () => void { + return editor.registerNodeTransform(TableCellNode, (node) => { + if (node.getColSpan() > 1 || node.getRowSpan() > 1) { + // When we have rowSpan we have to map the entire Table to understand where the new Cells + // fit best; let's analyze all Cells at once to save us from further transform iterations + const [, , gridNode] = $getNodeTriplet(node); + const [gridMap] = $computeTableMap(gridNode, node, node); + // TODO this function expects Tables to be normalized. Look into this once it exists + const rowsCount = gridMap.length; + const columnsCount = gridMap[0].length; + let row = gridNode.getFirstChild(); + invariant( + $isTableRowNode(row), + 'Expected TableNode first child to be a RowNode', + ); + const unmerged = []; + for (let i = 0; i < rowsCount; i++) { + if (i !== 0) { + row = row.getNextSibling(); + invariant( + $isTableRowNode(row), + 'Expected TableNode first child to be a RowNode', + ); + } + let lastRowCell: null | TableCellNode = null; + for (let j = 0; j < columnsCount; j++) { + const cellMap = gridMap[i][j]; + const cell = cellMap.cell; + if (cellMap.startRow === i && cellMap.startColumn === j) { + lastRowCell = cell; + unmerged.push(cell); + } else if (cell.getColSpan() > 1 || cell.getRowSpan() > 1) { + invariant( + $isTableCellNode(cell), + 'Expected TableNode cell to be a TableCellNode', + ); + const newCell = $createTableCellNode(cell.__headerState); + if (lastRowCell !== null) { + lastRowCell.insertAfter(newCell); + } else { + $insertFirst(row, newCell); + } + } + } + } + for (const cell of unmerged) { + cell.setColSpan(1); + cell.setRowSpan(1); + } + } + }); +} + +export function registerTableSelectionObserver( + editor: LexicalEditor, + hasTabHandler: boolean = true, +): () => void { + const tableSelections = new Map< + NodeKey, + [TableObserver, HTMLTableElementWithWithTableSelectionState] + >(); + + const initializeTableNode = ( + tableNode: TableNode, + nodeKey: NodeKey, + dom: HTMLElement, + ) => { + const tableElement = getTableElement(tableNode, dom); + const tableSelection = applyTableHandlers( + tableNode, + tableElement, + editor, + hasTabHandler, + ); + tableSelections.set(nodeKey, [tableSelection, tableElement]); + }; + + const unregisterMutationListener = editor.registerMutationListener( + TableNode, + (nodeMutations) => { + editor.getEditorState().read( + () => { + for (const [nodeKey, mutation] of nodeMutations) { + const tableSelection = tableSelections.get(nodeKey); + if (mutation === 'created' || mutation === 'updated') { + const {tableNode, tableElement} = + $getTableAndElementByKey(nodeKey); + if (tableSelection === undefined) { + initializeTableNode(tableNode, nodeKey, tableElement); + } else if (tableElement !== tableSelection[1]) { + // The update created a new DOM node, destroy the existing TableObserver + tableSelection[0].removeListeners(); + tableSelections.delete(nodeKey); + initializeTableNode(tableNode, nodeKey, tableElement); + } + } else if (mutation === 'destroyed') { + if (tableSelection !== undefined) { + tableSelection[0].removeListeners(); + tableSelections.delete(nodeKey); + } + } + } + }, + {editor}, + ); + }, + {skipInitialization: false}, + ); + + return () => { + unregisterMutationListener(); + // Hook might be called multiple times so cleaning up tables listeners as well, + // as it'll be reinitialized during recurring call + for (const [, [tableSelection]] of tableSelections) { + tableSelection.removeListeners(); + } + }; +} + +/** + * Register the INSERT_TABLE_COMMAND listener and the table integrity transforms. The + * table selection observer should be registered separately after this with + * {@link registerTableSelectionObserver}. + * + * @param editor The editor + * @returns An unregister callback + */ +export function registerTablePlugin(editor: LexicalEditor): () => void { + if (!editor.hasNodes([TableNode])) { + invariant(false, 'TablePlugin: TableNode is not registered on editor'); + } + return mergeRegister( + editor.registerCommand( + INSERT_TABLE_COMMAND, + $insertTableCommandListener, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerNodeTransform(TableNode, $tableTransform), + editor.registerNodeTransform(TableRowNode, $tableRowTransform), + editor.registerNodeTransform(TableCellNode, $tableCellTransform), + ); +} diff --git a/packages/lexical-table/src/LexicalTableRowNode.ts b/packages/lexical-table/src/LexicalTableRowNode.ts index fd8bcb8fa0a..4e216b865df 100644 --- a/packages/lexical-table/src/LexicalTableRowNode.ts +++ b/packages/lexical-table/src/LexicalTableRowNode.ts @@ -8,7 +8,7 @@ import type {BaseSelection, Spread} from 'lexical'; -import {addClassNamesToElement} from '@lexical/utils'; +import {$descendantsMatching, addClassNamesToElement} from '@lexical/utils'; import { $applyNodeReplacement, DOMConversionMap, @@ -21,6 +21,7 @@ import { } from 'lexical'; import {PIXEL_VALUE_REG_EXP} from './constants'; +import {$isTableCellNode} from './LexicalTableCellNode'; export type SerializedTableRowNode = Spread< { @@ -103,7 +104,7 @@ export class TableRowNode extends ElementNode { return this.getLatest().__height; } - updateDOM(prevNode: TableRowNode): boolean { + updateDOM(prevNode: this): boolean { return prevNode.__height !== this.__height; } @@ -124,7 +125,10 @@ export function $convertTableRowElement(domNode: Node): DOMConversionOutput { height = parseFloat(domNode_.style.height); } - return {node: $createTableRowNode(height)}; + return { + after: (children) => $descendantsMatching(children, $isTableCellNode), + node: $createTableRowNode(height), + }; } export function $createTableRowNode(height?: number): TableRowNode { diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 7959f1cfd75..5d010cc0bc2 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -60,6 +60,7 @@ import { FORMAT_TEXT_COMMAND, getDOMSelection, INSERT_PARAGRAPH_COMMAND, + isDOMNode, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, @@ -185,16 +186,19 @@ export function applyTableHandlers( }; const onMouseMove = (moveEvent: MouseEvent) => { + if (!isDOMNode(moveEvent.target)) { + return; + } if (!isMouseDownOnEvent(moveEvent) && tableObserver.isSelecting) { tableObserver.isSelecting = false; editorWindow.removeEventListener('mouseup', onMouseUp); editorWindow.removeEventListener('mousemove', onMouseMove); return; } - const override = !tableElement.contains(moveEvent.target as Node); + const override = !tableElement.contains(moveEvent.target); let focusCell: null | TableDOMCell = null; if (!override) { - focusCell = getDOMCellFromTarget(moveEvent.target as Node); + focusCell = getDOMCellFromTarget(moveEvent.target); } else { for (const el of document.elementsFromPoint( moveEvent.clientX, @@ -231,15 +235,11 @@ export function applyTableHandlers( }; const onMouseDown = (event: MouseEvent) => { - if (event.button !== 0) { - return; - } - - if (!editorWindow) { + if (event.button !== 0 || !isDOMNode(event.target) || !editorWindow) { return; } - const targetCell = getDOMCellFromTarget(event.target as Node); + const targetCell = getDOMCellFromTarget(event.target); if (targetCell !== null) { editor.update(() => { const prevSelection = $getPreviousSelection(); @@ -292,13 +292,13 @@ export function applyTableHandlers( // Clear selection when clicking outside of dom. const mouseDownCallback = (event: MouseEvent) => { - if (event.button !== 0) { + const target = event.target; + if (event.button !== 0 || !isDOMNode(target)) { return; } editor.update(() => { const selection = $getSelection(); - const target = event.target as Node; if ( $isTableSelection(selection) && selection.tableKey === tableObserver.tableNodeKey && diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts index e1c0c0884cd..84e4ab3f5a7 100644 --- a/packages/lexical-table/src/LexicalTableUtils.ts +++ b/packages/lexical-table/src/LexicalTableUtils.ts @@ -258,20 +258,31 @@ export function $insertTableRow__EXPERIMENTAL( $isRangeSelection(selection) || $isTableSelection(selection), 'Expected a RangeSelection or TableSelection', ); + const anchor = selection.anchor.getNode(); const focus = selection.focus.getNode(); + const [anchorCell] = $getNodeTriplet(anchor); const [focusCell, , grid] = $getNodeTriplet(focus); - const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell); + const [gridMap, focusCellMap, anchorCellMap] = $computeTableMap( + grid, + focusCell, + anchorCell, + ); const columnCount = gridMap[0].length; + const {startRow: anchorStartRow} = anchorCellMap; const {startRow: focusStartRow} = focusCellMap; let insertedRow: TableRowNode | null = null; if (insertAfter) { - const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; - const focusEndRowMap = gridMap[focusEndRow]; + const insertAfterEndRow = + Math.max( + focusStartRow + focusCell.__rowSpan, + anchorStartRow + anchorCell.__rowSpan, + ) - 1; + const insertAfterEndRowMap = gridMap[insertAfterEndRow]; const newRow = $createTableRowNode(); for (let i = 0; i < columnCount; i++) { - const {cell, startRow} = focusEndRowMap[i]; - if (startRow + cell.__rowSpan - 1 <= focusEndRow) { - const currentCell = focusEndRowMap[i].cell as TableCellNode; + const {cell, startRow} = insertAfterEndRowMap[i]; + if (startRow + cell.__rowSpan - 1 <= insertAfterEndRow) { + const currentCell = insertAfterEndRowMap[i].cell as TableCellNode; const currentCellHeaderState = currentCell.__headerState; const headerState = getHeaderState( @@ -286,20 +297,21 @@ export function $insertTableRow__EXPERIMENTAL( cell.setRowSpan(cell.__rowSpan + 1); } } - const focusEndRowNode = grid.getChildAtIndex(focusEndRow); + const insertAfterEndRowNode = grid.getChildAtIndex(insertAfterEndRow); invariant( - $isTableRowNode(focusEndRowNode), - 'focusEndRow is not a TableRowNode', + $isTableRowNode(insertAfterEndRowNode), + 'insertAfterEndRow is not a TableRowNode', ); - focusEndRowNode.insertAfter(newRow); + insertAfterEndRowNode.insertAfter(newRow); insertedRow = newRow; } else { - const focusStartRowMap = gridMap[focusStartRow]; + const insertBeforeStartRow = Math.min(focusStartRow, anchorStartRow); + const insertBeforeStartRowMap = gridMap[insertBeforeStartRow]; const newRow = $createTableRowNode(); for (let i = 0; i < columnCount; i++) { - const {cell, startRow} = focusStartRowMap[i]; - if (startRow === focusStartRow) { - const currentCell = focusStartRowMap[i].cell as TableCellNode; + const {cell, startRow} = insertBeforeStartRowMap[i]; + if (startRow === insertBeforeStartRow) { + const currentCell = insertBeforeStartRowMap[i].cell as TableCellNode; const currentCellHeaderState = currentCell.__headerState; const headerState = getHeaderState( @@ -314,12 +326,12 @@ export function $insertTableRow__EXPERIMENTAL( cell.setRowSpan(cell.__rowSpan + 1); } } - const focusStartRowNode = grid.getChildAtIndex(focusStartRow); + const insertBeforeStartRowNode = grid.getChildAtIndex(insertBeforeStartRow); invariant( - $isTableRowNode(focusStartRowNode), - 'focusEndRow is not a TableRowNode', + $isTableRowNode(insertBeforeStartRowNode), + 'insertBeforeStartRow is not a TableRowNode', ); - focusStartRowNode.insertBefore(newRow); + insertBeforeStartRowNode.insertBefore(newRow); insertedRow = newRow; } return insertedRow; @@ -548,6 +560,7 @@ export function $deleteTableRow__EXPERIMENTAL(): void { return; } const columnCount = gridMap[0].length; + const selectedRowCount = anchorCell.__rowSpan; const nextRow = gridMap[focusEndRow + 1]; const nextRowNode: null | TableRowNode = grid.getChildAtIndex( focusEndRow + 1, @@ -565,7 +578,11 @@ export function $deleteTableRow__EXPERIMENTAL(): void { } // Rows overflowing top have to be trimmed if (row === anchorStartRow && cellStartRow < anchorStartRow) { - cell.setRowSpan(cell.__rowSpan - (cellStartRow - anchorStartRow)); + const overflowTop = anchorStartRow - cellStartRow; + cell.setRowSpan( + cell.__rowSpan - + Math.min(selectedRowCount, cell.__rowSpan - overflowTop), + ); } // Rows overflowing bottom have to be trimmed and moved to the next row if ( @@ -574,11 +591,22 @@ export function $deleteTableRow__EXPERIMENTAL(): void { ) { cell.setRowSpan(cell.__rowSpan - (focusEndRow - cellStartRow + 1)); invariant(nextRowNode !== null, 'Expected nextRowNode not to be null'); - if (column === 0) { + let insertAfterCell: null | TableCellNode = null; + for (let columnIndex = 0; columnIndex < column; columnIndex++) { + const currentCellMap = nextRow[columnIndex]; + const currentCell = currentCellMap.cell; + // Checking the cell having startRow as same as nextRow + if (currentCellMap.startRow === row + 1) { + insertAfterCell = currentCell; + } + if (currentCell.__colSpan > 1) { + columnIndex += currentCell.__colSpan - 1; + } + } + if (insertAfterCell === null) { $insertFirst(nextRowNode, cell); } else { - const {cell: previousCell} = nextRow[column - 1]; - previousCell.insertAfter(cell); + insertAfterCell.insertAfter(cell); } } } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index 96ca3c7e426..c5f208c51f8 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -317,13 +317,77 @@ describe('LexicalTableNode tests', () => { ); }); - test('Copy table from an external source like gdoc with formatting', async () => { + test('Copy table with caption/tbody/thead/tfoot from an external source', async () => { const {editor} = testEnv; const dataTransfer = new DataTransferMock(); dataTransfer.setData( 'text/html', - '
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
', + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/thead + html` + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Council budget (in £) 2018 +
+ Items + + Expenditure +
+ Donuts + + 3,000 +
+ Stationery + + 18,000 +
+ Totals + + 21,000 +
+ `, ); await editor.update(() => { const selection = $getSelection(); @@ -333,6 +397,158 @@ describe('LexicalTableNode tests', () => { ); $insertDataTransferForRichText(dataTransfer, selection, editor); }); + // Here we are testing the createDOM, not the exportDOM, so the tbody is not there + expectTableHtmlToBeEqual( + testEnv.innerHTML, + html` + + + + + + + + + + + + + + + + + + + + + +
+

+ Items +

+
+

+ Expenditure +

+
+

+ Donuts +

+
+

+ 3,000 +

+
+

+ Stationery +

+
+

+ 18,000 +

+
+

+ Totals +

+
+

+ 21,000 +

+
+ `, + ); + }); + + test('Copy table with caption from an external source', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/caption + html` + + + + + + + + + + + + + + + + + + + + + + + + + +
+ He-Man and Skeletor facts +
+ He-Man + + Skeletor +
+ Role + + Hero + + Villain +
+ Weapon + + Power Sword + + Havoc Staff +
+ Dark secret + + Expert florist + + Cries at romcoms +
+ `, + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + // Here we are testing the createDOM, not the exportDOM, so the tbody is not there expectTableHtmlToBeEqual( testEnv.innerHTML, html` @@ -342,6 +558,102 @@ describe('LexicalTableNode tests', () => {
+


+
+

+ He-Man +

+
+

+ Skeletor +

+
+

+ Role +

+
+

+ Hero +

+
+

+ Villain +

+
+

+ Weapon +

+
+

+ Power Sword +

+
+

+ Havoc Staff +

+
+

+ Dark secret +

+
+

+ Expert florist +

+
+

+ Cries at romcoms +

+
+ `, + ); + }); + + test('Copy table from an external source like gdoc with formatting', async () => { + const {editor} = testEnv; + + const dataTransfer = new DataTransferMock(); + dataTransfer.setData( + 'text/html', + '
SurfaceMWP_WORK_LS_COMPOSER77349
LexicalXDS_RICH_TEXT_AREAsdvd sdfvsfs
', + ); + await editor.update(() => { + const selection = $getSelection(); + invariant( + $isRangeSelection(selection), + 'isRangeSelection(selection)', + ); + $insertDataTransferForRichText(dataTransfer, selection, editor); + }); + expectTableHtmlToBeEqual( + testEnv.innerHTML, + html` + + + + + +

diff --git a/packages/lexical-table/src/index.ts b/packages/lexical-table/src/index.ts index be452681b98..c4fe6ace096 100644 --- a/packages/lexical-table/src/index.ts +++ b/packages/lexical-table/src/index.ts @@ -29,6 +29,11 @@ export { } from './LexicalTableNode'; export type {TableDOMCell} from './LexicalTableObserver'; export {$getTableAndElementByKey, TableObserver} from './LexicalTableObserver'; +export { + registerTableCellUnmergeTransform, + registerTablePlugin, + registerTableSelectionObserver, +} from './LexicalTablePluginHelpers'; export type {SerializedTableRowNode} from './LexicalTableRowNode'; export { $createTableRowNode, diff --git a/packages/lexical-utils/flow/LexicalUtils.js.flow b/packages/lexical-utils/flow/LexicalUtils.js.flow index 11524eee950..958dd8acfa7 100644 --- a/packages/lexical-utils/flow/LexicalUtils.js.flow +++ b/packages/lexical-utils/flow/LexicalUtils.js.flow @@ -125,3 +125,14 @@ declare export function $splitNode( declare export function calculateZoomLevel(element: Element | null): number; declare export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean; + +declare export function $unwrapAndFilterDescendants( + root: ElementNode, + $predicate: (node: LexicalNode) => boolean, +): boolean; + +declare export function $firstToLastIterator(node: ElementNode): Iterable; + +declare export function $lastToFirstIterator(node: ElementNode): Iterable; + +declare export function $unwrapNode(node: ElementNode): void; diff --git a/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx b/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx new file mode 100644 index 00000000000..3a4e596626f --- /dev/null +++ b/packages/lexical-utils/src/__tests__/unit/descendantsMatching.test.tsx @@ -0,0 +1,84 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +import {$descendantsMatching} from '@lexical/utils'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isTextNode, + ParagraphNode, +} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; + +function assertClass(v: unknown, klass: Klass): T { + if (v instanceof klass) { + return v as T; + } + throw new Error(`Value does not extend ${klass.name}`); +} + +function $createTextAndParagraphWithDepth(depth: number): LexicalNode[] { + if (depth <= 0) { + return [$createTextNode(`<${depth} />`)]; + } + return [ + $createTextNode(`<${depth}>`), + $createParagraphNode().append( + ...$createTextAndParagraphWithDepth(depth - 1), + ), + $createTextNode(``), + ]; +} + +function textContentForDepth(i: number): string { + return i > 0 ? `<${i}>${textContentForDepth(i - 1)}` : `<${i} />`; +} + +describe('$descendantsMatching', () => { + let editor: LexicalEditor; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + [0, 1, 2].forEach((depth) => + it(`Can un-nest children at depth ${depth}`, () => { + editor.update( + () => { + const firstNode = $createParagraphNode(); + $getRoot() + .clear() + .append( + firstNode.append(...$createTextAndParagraphWithDepth(depth)), + ); + }, + {discrete: true}, + ); + editor.update( + () => { + const firstNode = assertClass( + $getRoot().getFirstChildOrThrow(), + ParagraphNode, + ); + expect(firstNode.getChildren().every($isTextNode)).toBe(depth === 0); + firstNode.splice( + 0, + firstNode.getChildrenSize(), + $descendantsMatching(firstNode.getChildren(), $isTextNode), + ); + expect(firstNode.getChildren().every($isTextNode)).toBe(true); + expect(firstNode.getTextContent()).toBe(textContentForDepth(depth)); + }, + {discrete: true}, + ); + }), + ); +}); diff --git a/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx b/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx new file mode 100644 index 00000000000..f7042e14e0e --- /dev/null +++ b/packages/lexical-utils/src/__tests__/unit/iterators.test.tsx @@ -0,0 +1,216 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +import {$firstToLastIterator, $lastToFirstIterator} from '@lexical/utils'; +import {$createParagraphNode, $createTextNode, TextNode} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; + +function assertClass(v: unknown, klass: Klass): T { + if (v instanceof klass) { + return v as T; + } + throw new Error(`Value does not extend ${klass.name}`); +} + +describe('$firstToLastIterator', () => { + let editor: LexicalEditor; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + it(`Iterates from first to last`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($firstToLastIterator(parent), (node) => { + return assertClass(node, TextNode).getTextContent(); + }), + ).toEqual(['0', '1', '2', '3', '4']); + // Parent was not affected + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + }, + {discrete: true}, + ); + }); + it(`Can handle node removal`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($firstToLastIterator(parent), (node) => { + const rval = assertClass(node, TextNode).getTextContent(); + node.remove(); + return rval; + }), + ).toEqual(['0', '1', '2', '3', '4']); + expect(parent.getChildren()).toEqual([]); + }, + {discrete: true}, + ); + }); + it(`Detects cycles when nodes move incorrectly`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect(() => + Array.from($firstToLastIterator(parent), (node) => { + const rval = assertClass(node, TextNode).getTextContent(); + parent.append(node); + return rval; + }), + ).toThrow(/\$childIterator: Cycle detected/); + }, + {discrete: true}, + ); + }); + it(`Can handle nodes moving in the other direction`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($firstToLastIterator(parent), (node) => { + const rval = assertClass(node, TextNode).getTextContent(); + if (node.getPreviousSibling() !== null) { + parent.splice(0, 0, [node]); + } + return rval; + }), + ).toEqual(['0', '1', '2', '3', '4']); + // This mutation reversed the nodes while traversing + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['4', '3', '2', '1', '0']); + }, + {discrete: true}, + ); + }); +}); + +describe('$lastToFirstIterator', () => { + let editor: LexicalEditor; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + it(`Iterates from last to first`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($lastToFirstIterator(parent), (node) => { + return assertClass(node, TextNode).getTextContent(); + }), + ).toEqual(['4', '3', '2', '1', '0']); + // Parent was not affected + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + }, + {discrete: true}, + ); + }); + it(`Can handle node removal`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($lastToFirstIterator(parent), (node) => { + const rval = assertClass(node, TextNode).getTextContent(); + node.remove(); + return rval; + }), + ).toEqual(['4', '3', '2', '1', '0']); + expect(parent.getChildren()).toEqual([]); + }, + {discrete: true}, + ); + }); + it(`Can handle nodes moving in the other direction`, () => { + editor.update( + () => { + const parent = $createParagraphNode().splice( + 0, + 0, + Array.from({length: 5}, (_v, i) => $createTextNode(`${i}`)), + ); + // Check initial state + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['0', '1', '2', '3', '4']); + expect( + Array.from($lastToFirstIterator(parent), (node) => { + const rval = assertClass(node, TextNode).getTextContent(); + parent.append(node); + return rval; + }), + ).toEqual(['4', '3', '2', '1', '0']); + // This mutation reversed the nodes while traversing + expect( + parent.getAllTextNodes().map((node) => node.getTextContent()), + ).toEqual(['4', '3', '2', '1', '0']); + }, + {discrete: true}, + ); + }); +}); diff --git a/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx b/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx new file mode 100644 index 00000000000..cb42d0fe643 --- /dev/null +++ b/packages/lexical-utils/src/__tests__/unit/unwrapAndFilterDescendants.test.tsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import type {Klass, LexicalEditor, LexicalNode} from 'lexical'; + +import {$unwrapAndFilterDescendants} from '@lexical/utils'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $isParagraphNode, + $isTextNode, + ParagraphNode, +} from 'lexical'; +import {createTestEditor} from 'lexical/src/__tests__/utils'; + +function assertClass(v: unknown, klass: Klass): T { + if (v instanceof klass) { + return v as T; + } + throw new Error(`Value does not extend ${klass.name}`); +} + +function $createTextAndParagraphWithDepth(depth: number): LexicalNode[] { + if (depth <= 0) { + return [$createTextNode(`<${depth} />`)]; + } + return [ + $createTextNode(`<${depth}>`), + $createParagraphNode().append( + ...$createTextAndParagraphWithDepth(depth - 1), + ), + $createTextNode(``), + ]; +} + +function textContentForDepth(i: number): string { + return i > 0 ? `<${i}>${textContentForDepth(i - 1)}` : `<${i} />`; +} + +describe('$unwrapAndFilterDescendants', () => { + let editor: LexicalEditor; + + beforeEach(async () => { + editor = createTestEditor(); + editor._headless = true; + }); + + it('Is a no-op with valid children', () => { + editor.update( + () => { + $getRoot().clear().append($createParagraphNode()); + }, + {discrete: true}, + ); + editor.update( + () => { + expect($unwrapAndFilterDescendants($getRoot(), $isParagraphNode)).toBe( + false, + ); + expect($getRoot().getChildrenSize()).toBe(1); + expect($isParagraphNode($getRoot().getFirstChild())).toBe(true); + }, + {discrete: true}, + ); + }); + [0, 1, 2].forEach((depth) => + it(`Can un-nest children at depth ${depth}`, () => { + editor.update( + () => { + const firstNode = $createParagraphNode(); + $getRoot() + .clear() + .append( + firstNode.append(...$createTextAndParagraphWithDepth(depth)), + ); + }, + {discrete: true}, + ); + editor.update( + () => { + const firstNode = assertClass( + $getRoot().getFirstChildOrThrow(), + ParagraphNode, + ); + expect(firstNode.getChildren().every($isTextNode)).toBe(depth === 0); + expect($unwrapAndFilterDescendants(firstNode, $isTextNode)).toBe( + depth > 0, + ); + expect(firstNode.getChildren().every($isTextNode)).toBe(true); + expect(firstNode.getTextContent()).toBe(textContentForDepth(depth)); + }, + {discrete: true}, + ); + }), + ); +}); diff --git a/packages/lexical-utils/src/index.ts b/packages/lexical-utils/src/index.ts index 8994e3dad65..0a758f40f2f 100644 --- a/packages/lexical-utils/src/index.ts +++ b/packages/lexical-utils/src/index.ts @@ -23,6 +23,7 @@ import { Klass, LexicalEditor, LexicalNode, + NodeKey, } from 'lexical'; // This underscore postfixing is used as a hotfix so we do not // export shared types from this module #5918 @@ -690,3 +691,157 @@ export function calculateZoomLevel(element: Element | null): number { export function $isEditorIsNestedEditor(editor: LexicalEditor): boolean { return editor._parentEditor !== null; } + +/** + * A depth first last-to-first traversal of root that stops at each node that matches + * $predicate and ensures that its parent is root. This is typically used to discard + * invalid or unsupported wrapping nodes. For example, a TableNode must only have + * TableRowNode as children, but an importer might add invalid nodes based on + * caption, tbody, thead, etc. and this will unwrap and discard those. + * + * @param root The root to start the traversal + * @param $predicate Should return true for nodes that are permitted to be children of root + * @returns true if this unwrapped or removed any nodes + */ +export function $unwrapAndFilterDescendants( + root: ElementNode, + $predicate: (node: LexicalNode) => boolean, +): boolean { + return $unwrapAndFilterDescendantsImpl(root, $predicate, null); +} + +function $unwrapAndFilterDescendantsImpl( + root: ElementNode, + $predicate: (node: LexicalNode) => boolean, + $onSuccess: null | ((node: LexicalNode) => void), +): boolean { + let didMutate = false; + for (const node of $lastToFirstIterator(root)) { + if ($predicate(node)) { + if ($onSuccess !== null) { + $onSuccess(node); + } + continue; + } + didMutate = true; + if ($isElementNode(node)) { + $unwrapAndFilterDescendantsImpl( + node, + $predicate, + $onSuccess ? $onSuccess : (child) => node.insertAfter(child), + ); + } + node.remove(); + } + return didMutate; +} + +/** + * A depth first traversal of the children array that stops at and collects + * each node that `$predicate` matches. This is typically used to discard + * invalid or unsupported wrapping nodes on a children array in the `after` + * of an {@link lexical!DOMConversionOutput}. For example, a TableNode must only have + * TableRowNode as children, but an importer might add invalid nodes based on + * caption, tbody, thead, etc. and this will unwrap and discard those. + * + * This function is read-only and performs no mutation operations, which makes + * it suitable for import and export purposes but likely not for any in-place + * mutation. You should use {@link $unwrapAndFilterDescendants} for in-place + * mutations such as node transforms. + * + * @param children The children to traverse + * @param $predicate Should return true for nodes that are permitted to be children of root + * @returns The children or their descendants that match $predicate + */ +export function $descendantsMatching( + children: LexicalNode[], + $predicate: (node: LexicalNode) => node is T, +): T[]; +export function $descendantsMatching( + children: LexicalNode[], + $predicate: (node: LexicalNode) => boolean, +): LexicalNode[] { + const result: LexicalNode[] = []; + const stack = [...children].reverse(); + for (let child = stack.pop(); child !== undefined; child = stack.pop()) { + if ($predicate(child)) { + result.push(child); + } else if ($isElementNode(child)) { + for (const grandchild of $lastToFirstIterator(child)) { + stack.push(grandchild); + } + } + } + return result; +} + +/** + * Return an iterator that yields each child of node from first to last, taking + * care to preserve the next sibling before yielding the value in case the caller + * removes the yielded node. + * + * @param node The node whose children to iterate + * @returns An iterator of the node's children + */ +export function $firstToLastIterator(node: ElementNode): Iterable { + return { + [Symbol.iterator]: () => + $childIterator(node.getFirstChild(), (child) => child.getNextSibling()), + }; +} + +/** + * Return an iterator that yields each child of node from last to first, taking + * care to preserve the previous sibling before yielding the value in case the caller + * removes the yielded node. + * + * @param node The node whose children to iterate + * @returns An iterator of the node's children + */ +export function $lastToFirstIterator(node: ElementNode): Iterable { + return { + [Symbol.iterator]: () => + $childIterator(node.getLastChild(), (child) => + child.getPreviousSibling(), + ), + }; +} + +function $childIterator( + initialNode: LexicalNode | null, + nextNode: (node: LexicalNode) => LexicalNode | null, +): Iterator { + let state = initialNode; + const seen = __DEV__ ? new Set() : null; + return { + next() { + if (state === null) { + return iteratorDone; + } + const rval = iteratorNotDone(state); + if (__DEV__ && seen !== null) { + const key = state.getKey(); + invariant( + !seen.has(key), + '$childIterator: Cycle detected, node with key %s has already been traversed', + String(key), + ); + seen.add(key); + } + state = nextNode(state); + return rval; + }, + }; +} + +/** + * Insert all children before this node, and then remove it. + * + * @param node The ElementNode to unwrap and remove + */ +export function $unwrapNode(node: ElementNode): void { + for (const child of $firstToLastIterator(node)) { + node.insertBefore(child); + } + node.remove(); +} diff --git a/packages/lexical-utils/src/markSelection.ts b/packages/lexical-utils/src/markSelection.ts index 382b57b4bb7..458aa230fae 100644 --- a/packages/lexical-utils/src/markSelection.ts +++ b/packages/lexical-utils/src/markSelection.ts @@ -8,12 +8,13 @@ import { $getSelection, + $isElementNode, $isRangeSelection, - $isTextNode, type EditorState, ElementNode, getDOMTextNode, type LexicalEditor, + Point, TextNode, } from 'lexical'; @@ -21,6 +22,40 @@ import mergeRegister from './mergeRegister'; import positionNodeOnRange from './positionNodeOnRange'; import px from './px'; +function rangeTargetFromPoint( + point: Point, + node: ElementNode | TextNode, + dom: HTMLElement, +): [HTMLElement | Text, number] { + if (point.type === 'text' || !$isElementNode(node)) { + const textDOM = getDOMTextNode(dom) || dom; + return [textDOM, point.offset]; + } else { + const slot = node.getDOMSlot(dom); + return [slot.element, slot.getFirstChildOffset() + point.offset]; + } +} + +function rangeFromPoints( + editor: LexicalEditor, + anchor: Point, + anchorNode: ElementNode | TextNode, + anchorDOM: HTMLElement, + focus: Point, + focusNode: ElementNode | TextNode, + focusDOM: HTMLElement, +): Range { + const editorDocument = editor._window ? editor._window.document : document; + const range = editorDocument.createRange(); + if (focusNode.isBefore(anchorNode)) { + range.setStart(...rangeTargetFromPoint(focus, focusNode, focusDOM)); + range.setEnd(...rangeTargetFromPoint(anchor, anchorNode, anchorDOM)); + } else { + range.setStart(...rangeTargetFromPoint(anchor, anchorNode, anchorDOM)); + range.setEnd(...rangeTargetFromPoint(focus, focusNode, focusDOM)); + } + return range; +} /** * Place one or multiple newly created Nodes at the current selection. Multiple * nodes will only be created when the selection spans multiple lines (aka @@ -34,8 +69,10 @@ export default function markSelection( onReposition?: (node: Array) => void, ): () => void { let previousAnchorNode: null | TextNode | ElementNode = null; + let previousAnchorNodeDOM: null | HTMLElement = null; let previousAnchorOffset: null | number = null; let previousFocusNode: null | TextNode | ElementNode = null; + let previousFocusNodeDOM: null | HTMLElement = null; let previousFocusOffset: null | number = null; let removeRangeListener: () => void = () => {}; function compute(editorState: EditorState) { @@ -62,101 +99,66 @@ export default function markSelection( const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey); const differentAnchorDOM = previousAnchorNode === null || - currentAnchorNodeDOM === null || + currentAnchorNodeDOM !== previousAnchorNodeDOM || currentAnchorOffset !== previousAnchorOffset || - currentAnchorNodeKey !== previousAnchorNode.getKey() || - (currentAnchorNode !== previousAnchorNode && - (!$isTextNode(previousAnchorNode) || - currentAnchorNode.updateDOM( - previousAnchorNode, - currentAnchorNodeDOM, - editor._config, - ))); + currentAnchorNodeKey !== previousAnchorNode.getKey(); const differentFocusDOM = previousFocusNode === null || - currentFocusNodeDOM === null || + currentFocusNodeDOM !== previousFocusNodeDOM || currentFocusOffset !== previousFocusOffset || - currentFocusNodeKey !== previousFocusNode.getKey() || - (currentFocusNode !== previousFocusNode && - (!$isTextNode(previousFocusNode) || - currentFocusNode.updateDOM( - previousFocusNode, - currentFocusNodeDOM, - editor._config, - ))); - if (differentAnchorDOM || differentFocusDOM) { - const anchorHTMLElement = editor.getElementByKey( - anchor.getNode().getKey(), + currentFocusNodeKey !== previousFocusNode.getKey(); + if ( + (differentAnchorDOM || differentFocusDOM) && + currentAnchorNodeDOM !== null && + currentFocusNodeDOM !== null + ) { + const range = rangeFromPoints( + editor, + anchor, + currentAnchorNode, + currentAnchorNodeDOM, + focus, + currentFocusNode, + currentFocusNodeDOM, ); - const focusHTMLElement = editor.getElementByKey( - focus.getNode().getKey(), - ); - if (anchorHTMLElement !== null && focusHTMLElement !== null) { - const range = document.createRange(); - let firstHTMLElement; - let firstOffset; - let lastHTMLElement; - let lastOffset; - if (focus.isBefore(anchor)) { - firstHTMLElement = focusHTMLElement; - firstOffset = focus.offset; - lastHTMLElement = anchorHTMLElement; - lastOffset = anchor.offset; - } else { - firstHTMLElement = anchorHTMLElement; - firstOffset = anchor.offset; - lastHTMLElement = focusHTMLElement; - lastOffset = focus.offset; - } - const firstHTMLElementTextChild = getDOMTextNode(firstHTMLElement); - const lastHTMLElementtextChild = getDOMTextNode(lastHTMLElement); - range.setStart( - firstHTMLElementTextChild || firstHTMLElement, - firstOffset, - ); - range.setEnd(lastHTMLElementtextChild || lastHTMLElement, lastOffset); - removeRangeListener(); - removeRangeListener = positionNodeOnRange( - editor, - range, - (domNodes) => { - if (onReposition === undefined) { - for (const domNode of domNodes) { - const domNodeStyle = domNode.style; + removeRangeListener(); + removeRangeListener = positionNodeOnRange(editor, range, (domNodes) => { + if (onReposition === undefined) { + for (const domNode of domNodes) { + const domNodeStyle = domNode.style; - if (domNodeStyle.background !== 'Highlight') { - domNodeStyle.background = 'Highlight'; - } - if (domNodeStyle.color !== 'HighlightText') { - domNodeStyle.color = 'HighlightText'; - } - if (domNodeStyle.marginTop !== px(-1.5)) { - domNodeStyle.marginTop = px(-1.5); - } - if (domNodeStyle.paddingTop !== px(4)) { - domNodeStyle.paddingTop = px(4); - } - if (domNodeStyle.paddingBottom !== px(0)) { - domNodeStyle.paddingBottom = px(0); - } - } - } else { - onReposition(domNodes); + if (domNodeStyle.background !== 'Highlight') { + domNodeStyle.background = 'Highlight'; + } + if (domNodeStyle.color !== 'HighlightText') { + domNodeStyle.color = 'HighlightText'; + } + if (domNodeStyle.marginTop !== px(-1.5)) { + domNodeStyle.marginTop = px(-1.5); } - }, - ); - } + if (domNodeStyle.paddingTop !== px(4)) { + domNodeStyle.paddingTop = px(4); + } + if (domNodeStyle.paddingBottom !== px(0)) { + domNodeStyle.paddingBottom = px(0); + } + } + } else { + onReposition(domNodes); + } + }); } previousAnchorNode = currentAnchorNode; + previousAnchorNodeDOM = currentAnchorNodeDOM; previousAnchorOffset = currentAnchorOffset; previousFocusNode = currentFocusNode; + previousFocusNodeDOM = currentFocusNodeDOM; previousFocusOffset = currentFocusOffset; }); } compute(editor.getEditorState()); return mergeRegister( editor.registerUpdateListener(({editorState}) => compute(editorState)), - removeRangeListener, () => { removeRangeListener(); }, diff --git a/packages/lexical-utils/src/positionNodeOnRange.ts b/packages/lexical-utils/src/positionNodeOnRange.ts index 2ae7df5e35e..4202aa51a03 100644 --- a/packages/lexical-utils/src/positionNodeOnRange.ts +++ b/packages/lexical-utils/src/positionNodeOnRange.ts @@ -121,7 +121,7 @@ export default function mlcPositionNodeOnRange( return stop(); } const currentParentDOMNode = currentRootDOMNode.parentElement; - if (currentParentDOMNode === null || !isHTMLElement(currentParentDOMNode)) { + if (!isHTMLElement(currentParentDOMNode)) { return stop(); } stop(); diff --git a/packages/lexical-website/docs/concepts/commands.md b/packages/lexical-website/docs/concepts/commands.md index a773b640186..5456058c3cf 100644 --- a/packages/lexical-website/docs/concepts/commands.md +++ b/packages/lexical-website/docs/concepts/commands.md @@ -31,6 +31,8 @@ editor.registerCommand( Commands can be dispatched from anywhere you have access to the `editor` such as a Toolbar Button, an event listener, or a Plugin, but most of the core commands are dispatched from [`LexicalEvents.ts`](https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalEvents.ts). +Calling `dispatchCommand` will implicitly call `editor.update` to trigger its command listeners if it was not called from inside `editor.update`. + ```js editor.dispatchCommand(command, payload); ``` @@ -70,6 +72,10 @@ editor.registerCommand( You can register a command from anywhere you have access to the `editor` object, but it's important that you remember to clean up the listener with its remove listener callback when it's no longer needed. +The command listener will always be called from an `editor.update`, so you may use dollar functions. You should not use +`editor.update` (and *never* call `editor.read`) synchronously from within a command listener. It is safe to call +`editor.getEditorState().read` if you need to read the previous state after updates have already been made. + ```js const removeListener = editor.registerCommand( COMMAND, diff --git a/packages/lexical-website/docs/concepts/nodes.md b/packages/lexical-website/docs/concepts/nodes.md index baa60eb92c5..99526c6d082 100644 --- a/packages/lexical-website/docs/concepts/nodes.md +++ b/packages/lexical-website/docs/concepts/nodes.md @@ -183,7 +183,7 @@ export class CustomParagraph extends ElementNode { return dom; } - updateDOM(prevNode: CustomParagraph, dom: HTMLElement): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { // Returning false tells Lexical that this node does not need its // DOM element replacing with a new copy from createDOM. return false; @@ -231,7 +231,7 @@ export class ColoredNode extends TextNode { } updateDOM( - prevNode: ColoredNode, + prevNode: this, dom: HTMLElement, config: EditorConfig, ): boolean { diff --git a/packages/lexical-website/docs/concepts/selection.md b/packages/lexical-website/docs/concepts/selection.md index e1c1a152fd9..0923b3befa1 100644 --- a/packages/lexical-website/docs/concepts/selection.md +++ b/packages/lexical-website/docs/concepts/selection.md @@ -116,4 +116,54 @@ editor.update(() => { // You can also clear selection by setting it to `null`. $setSelection(null); }); -``` \ No newline at end of file +``` + +## Focus + +You may notice that when you issue an `editor.update` or +`editor.dispatchCommand` then the editor can "steal focus" if there is +a selection and the editor is editable. This is because the Lexical +selection is reconciled to the DOM selection during reconciliation, +and the browser's focus follows its DOM selection. + +If you want to make updates or dispatch commands to the editor without +changing the selection, can use the `'skip-dom-selection'` update tag +(added in v0.22.0): + +```js +// Call this from an editor.update or command listener +$addUpdateTag('skip-dom-selection'); +``` + +If you want to add this tag during processing of a `dispatchCommand`, +you can wrap it in an `editor.update`: + +```js +// NOTE: If you are already in a command listener or editor.update, +// do *not* nest a second editor.update! Nested updates have +// confusing semantics (dispatchCommand will re-use the +// current update without nesting) +editor.update(() => { + $addUpdateTag('skip-dom-selection'); + editor.dispatchCommand(/* … */); +}); +``` + +If you have to support older versions of Lexical, you can mark the editor +as not editable during the update or dispatch. + +```js +// NOTE: This code should be *outside* of your update or command listener, e.g. +// directly in the DOM event listener +const prevEditable = editor.isEditable(); +editor.setEditable(false); +editor.update( + () => { + // run your update code or editor.dispatchCommand in here + }, { + onUpdate: () => { + editor.setEditable(prevEditable); + }, + }, +); +``` diff --git a/packages/lexical-website/docs/react/plugins.md b/packages/lexical-website/docs/react/plugins.md index 3f6d24cc9d5..ca97f7935ca 100644 --- a/packages/lexical-website/docs/react/plugins.md +++ b/packages/lexical-website/docs/react/plugins.md @@ -41,7 +41,7 @@ const initialConfig = { ### `LexicalPlainTextPlugin` -React wrapper for `@lexical/plain-text` that adds major features for plain text editing, including typing, deletion and copy/pasting +React wrapper for `@lexical/plain-text` that adds major features for plain text editing, including typing, deletion and copy/pasting. ```jsx @@ -73,7 +73,7 @@ Plugin that calls `onChange` whenever Lexical state is updated. Using `ignoreHis ### `LexicalHistoryPlugin` -React wrapper for `@lexical/history` that adds support for history stack management and `undo` / `redo` commands +React wrapper for `@lexical/history` that adds support for history stack management and `undo` / `redo` commands. ```jsx @@ -81,7 +81,7 @@ React wrapper for `@lexical/history` that adds support for history stack managem ### `LexicalLinkPlugin` -React wrapper for `@lexical/link` that adds support for links, including `$toggleLink` command support that toggles link for selected text +React wrapper for `@lexical/link` that adds support for links, including `$toggleLink` command support that toggles link for selected text. ```jsx @@ -107,7 +107,7 @@ React wrapper for `@lexical/list` that adds support for check lists. Note that i [![See API Documentation](/img/see-api-documentation.svg)](/docs/api/modules/lexical_react_LexicalTablePlugin) -React wrapper for `@lexical/table` that adds support for tables +React wrapper for `@lexical/table` that adds support for tables. ```jsx @@ -153,7 +153,7 @@ const MATCHERS = [ ### `LexicalClearEditorPlugin` -Adds `clearEditor` command support to clear editor's content +Adds `clearEditor` command support to clear editor's content. ```jsx @@ -161,7 +161,7 @@ Adds `clearEditor` command support to clear editor's content ### `LexicalMarkdownShortcutPlugin` -Adds markdown shortcut support: headings, lists, code blocks, quotes, links and inline styles (bold, italic, strikethrough) +Adds markdown shortcut support: headings, lists, code blocks, quotes, links and inline styles (bold, italic, strikethrough). ```jsx diff --git a/packages/lexical-website/docusaurus.config.js b/packages/lexical-website/docusaurus.config.js index 4559ce4ca2c..33c47913249 100644 --- a/packages/lexical-website/docusaurus.config.js +++ b/packages/lexical-website/docusaurus.config.js @@ -335,6 +335,10 @@ const config = { }, { items: [ + { + href: 'https://discord.gg/KmG4wQnnD9', + label: 'Discord', + }, { href: 'https://stackoverflow.com/questions/tagged/lexicaljs', label: 'Stack Overflow', diff --git a/packages/lexical/flow/Lexical.js.flow b/packages/lexical/flow/Lexical.js.flow index dccc5987079..1779c2f272a 100644 --- a/packages/lexical/flow/Lexical.js.flow +++ b/packages/lexical/flow/Lexical.js.flow @@ -80,7 +80,10 @@ declare export var IS_ITALIC: number; declare export var IS_STRIKETHROUGH: number; declare export var IS_SUBSCRIPT: number; declare export var IS_SUPERSCRIPT: number; -declare export var IS_UNDERLIN: number; +declare export var IS_UNDERLINE: number; +declare export var IS_UPPERCASE: number; +declare export var IS_LOWERCASE: number; +declare export var IS_CAPITALIZE: number; declare export var TEXT_TYPE_TO_FORMAT: Record; /** @@ -243,6 +246,9 @@ type TextNodeThemeClasses = { code?: EditorThemeClassName, subscript?: EditorThemeClassName, superscript?: EditorThemeClassName, + lowercase?: EditorThemeClassName, + uppercase?: EditorThemeClassName, + capitalize?: EditorThemeClassName, }; export type EditorThemeClasses = { characterLimit?: EditorThemeClassName, @@ -415,8 +421,7 @@ declare export class LexicalNode { getTextContentSize(includeDirectionless?: boolean): number; createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement; updateDOM( - // $FlowFixMe - prevNode: any, + prevNode: this, dom: HTMLElement, config: EditorConfig, ): boolean; @@ -585,7 +590,11 @@ export type TextFormatType = | 'highlight' | 'code' | 'subscript' - | 'superscript'; + | 'superscript' + | 'lowercase' + | 'uppercase' + | 'capitalize'; + type TextModeType = 'normal' | 'token' | 'segmented'; declare export class TextNode extends LexicalNode { @@ -611,11 +620,6 @@ declare export class TextNode extends LexicalNode { getTextContent(): string; getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number; createDOM(config: EditorConfig): HTMLElement; - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean; selectionTransform( prevSelection: null | BaseSelection, nextSelection: RangeSelection, @@ -707,7 +711,6 @@ declare export class RootNode extends ElementNode { replace(node: N): N; insertBefore(nodeToInsert: T): T; insertAfter(nodeToInsert: T): T; - updateDOM(prevNode: RootNode, dom: HTMLElement): false; append(...nodesToAppend: Array): this; canBeEmpty(): false; } @@ -850,7 +853,6 @@ declare export class ParagraphNode extends ElementNode { static clone(node: ParagraphNode): ParagraphNode; constructor(key?: NodeKey): void; createDOM(config: EditorConfig): HTMLElement; - updateDOM(prevNode: ParagraphNode, dom: HTMLElement): boolean; insertNewAfter( selection: RangeSelection, restoreSelection?: boolean, diff --git a/packages/lexical/src/LexicalConstants.ts b/packages/lexical/src/LexicalConstants.ts index 81b86a372ef..aead3dbddff 100644 --- a/packages/lexical/src/LexicalConstants.ts +++ b/packages/lexical/src/LexicalConstants.ts @@ -44,6 +44,9 @@ export const IS_CODE = 1 << 4; export const IS_SUBSCRIPT = 1 << 5; export const IS_SUPERSCRIPT = 1 << 6; export const IS_HIGHLIGHT = 1 << 7; +export const IS_LOWERCASE = 1 << 8; +export const IS_UPPERCASE = 1 << 9; +export const IS_CAPITALIZE = 1 << 10; export const IS_ALL_FORMATTING = IS_BOLD | @@ -53,7 +56,10 @@ export const IS_ALL_FORMATTING = IS_CODE | IS_SUBSCRIPT | IS_SUPERSCRIPT | - IS_HIGHLIGHT; + IS_HIGHLIGHT | + IS_LOWERCASE | + IS_UPPERCASE | + IS_CAPITALIZE; // Text node details export const IS_DIRECTIONLESS = 1; @@ -97,13 +103,16 @@ export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']'); export const TEXT_TYPE_TO_FORMAT: Record = { bold: IS_BOLD, + capitalize: IS_CAPITALIZE, code: IS_CODE, highlight: IS_HIGHLIGHT, italic: IS_ITALIC, + lowercase: IS_LOWERCASE, strikethrough: IS_STRIKETHROUGH, subscript: IS_SUBSCRIPT, superscript: IS_SUPERSCRIPT, underline: IS_UNDERLINE, + uppercase: IS_UPPERCASE, }; export const DETAIL_TYPE_TO_DETAIL: Record = { diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 1a67571c1a0..1961c8f4f34 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -37,7 +37,7 @@ import { getCachedTypeToNodeMap, getDefaultView, getDOMSelection, - markAllNodesAsDirty, + markNodesWithTypesAsDirty, } from './LexicalUtils'; import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; import {DecoratorNode} from './nodes/LexicalDecoratorNode'; @@ -69,6 +69,9 @@ export type TextNodeThemeClasses = { code?: EditorThemeClassName; highlight?: EditorThemeClassName; italic?: EditorThemeClassName; + lowercase?: EditorThemeClassName; + uppercase?: EditorThemeClassName; + capitalize?: EditorThemeClassName; strikethrough?: EditorThemeClassName; subscript?: EditorThemeClassName; superscript?: EditorThemeClassName; @@ -131,6 +134,7 @@ export type EditorThemeClasses = { quote?: EditorThemeClassName; root?: EditorThemeClassName; rtl?: EditorThemeClassName; + tab?: EditorThemeClassName; table?: EditorThemeClassName; tableAddColumns?: EditorThemeClassName; tableAddRows?: EditorThemeClassName; @@ -278,11 +282,11 @@ export type LexicalCommand = { * * editor.registerCommand(MY_COMMAND, payload => { * // Type of `payload` is inferred here. But lets say we want to extract a function to delegate to - * handleMyCommand(editor, payload); + * $handleMyCommand(editor, payload); * return true; * }); * - * function handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) { + * function $handleMyCommand(editor: LexicalEditor, payload: CommandPayloadType) { * // `payload` is of type `SomeType`, extracted from the command. * } * ``` @@ -774,14 +778,24 @@ export class LexicalEditor { } /** * Registers a listener that will trigger anytime the provided command - * is dispatched, subject to priority. Listeners that run at a higher priority can "intercept" - * commands and prevent them from propagating to other handlers by returning true. + * is dispatched with {@link LexicalEditor.dispatch}, subject to priority. + * Listeners that run at a higher priority can "intercept" commands and + * prevent them from propagating to other handlers by returning true. * - * Listeners registered at the same priority level will run deterministically in the order of registration. + * Listeners are always invoked in an {@link LexicalEditor.update} and can + * call dollar functions. + * + * Listeners registered at the same priority level will run + * deterministically in the order of registration. * * @param command - the command that will trigger the callback. * @param listener - the function that will execute when the command is dispatched. * @param priority - the relative priority of the listener. 0 | 1 | 2 | 3 | 4 + * (or {@link COMMAND_PRIORITY_EDITOR} | + * {@link COMMAND_PRIORITY_LOW} | + * {@link COMMAND_PRIORITY_NORMAL} | + * {@link COMMAND_PRIORITY_HIGH} | + * {@link COMMAND_PRIORITY_CRITICAL}) * @returns a teardown function that can be used to cleanup the listener. */ registerCommand

( @@ -959,7 +973,10 @@ export class LexicalEditor { registeredNodes.push(registeredReplaceWithNode); } - markAllNodesAsDirty(this, klass.getType()); + markNodesWithTypesAsDirty( + this, + registeredNodes.map((node) => node.klass.getType()), + ); return () => { registeredNodes.forEach((node) => node.transforms.delete(listener as Transform), @@ -988,7 +1005,10 @@ export class LexicalEditor { /** * Dispatches a command of the specified type with the specified payload. * This triggers all command listeners (set by {@link LexicalEditor.registerCommand}) - * for this type, passing them the provided payload. + * for this type, passing them the provided payload. The command listeners + * will be triggered in an implicit {@link LexicalEditor.update}, unless + * this was invoked from inside an update in which case that update context + * will be re-used (as if this was a dollar function itself). * @param type - the type of command listeners to trigger. * @param payload - the data to pass as an argument to the command listeners. */ diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 663fbb236a4..662cd81fe2d 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -108,6 +108,7 @@ import { isDeleteLineForward, isDeleteWordBackward, isDeleteWordForward, + isDOMNode, isEscape, isFirefoxClipboardEvents, isItalic, @@ -481,7 +482,7 @@ function onPointerDown(event: PointerEvent, editor: LexicalEditor) { // TODO implement text drag & drop const target = event.target; const pointerType = event.pointerType; - if (target instanceof Node && pointerType !== 'touch') { + if (isDOMNode(target) && pointerType !== 'touch' && event.button === 0) { updateEditor(editor, () => { // Drag & drop should not recompute selection until mouse up; otherwise the initially // selected content is lost. diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index fa58ebde193..f4864f60f67 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -34,6 +34,7 @@ import { internalGetRoot, isDOMUnmanaged, isFirefoxClipboardEvents, + isHTMLElement, } from './LexicalUtils'; // The time between a text entry event and the mutation observer firing. const TEXT_MUTATION_VARIANCE = 100; @@ -130,7 +131,9 @@ function $getNearestManagedNodePairFromDOMNode( const node = $getNodeByKey(key, editorState); if (node) { // All decorator nodes are unmanaged - return $isDecoratorNode(node) ? undefined : [dom as HTMLElement, node]; + return $isDecoratorNode(node) || !isHTMLElement(dom) + ? undefined + : [dom, node]; } } else if (dom === rootElement) { return [rootElement, internalGetRoot(editorState)]; @@ -209,7 +212,8 @@ export function $flushMutations( ) { if (IS_FIREFOX) { const possibleText = - (addedDOM as HTMLElement).innerText || addedDOM.nodeValue; + (isHTMLElement(addedDOM) ? addedDOM.innerText : null) || + addedDOM.nodeValue; if (possibleText) { possibleTextForFirefoxPaste += possibleText; diff --git a/packages/lexical/src/LexicalUpdates.ts b/packages/lexical/src/LexicalUpdates.ts index 11296a2be5a..1b2a83070dc 100644 --- a/packages/lexical/src/LexicalUpdates.ts +++ b/packages/lexical/src/LexicalUpdates.ts @@ -558,7 +558,7 @@ export function $commitPendingUpdates( return; } finally { - observer.observe(rootElement as Node, observerOptions); + observer.observe(rootElement, observerOptions); editor._updating = previouslyUpdating; activeEditorState = previousActiveEditorState; isReadOnlyMode = previousReadOnlyMode; @@ -607,7 +607,9 @@ export function $commitPendingUpdates( editor._editable && // domSelection will be null in headless domSelection !== null && - (needsUpdate || pendingSelection === null || pendingSelection.dirty) + (needsUpdate || pendingSelection === null || pendingSelection.dirty) && + rootElement !== null && + !tags.has('skip-dom-selection') ) { activeEditor = editor; activeEditorState = pendingEditorState; @@ -618,11 +620,7 @@ export function $commitPendingUpdates( if (needsUpdate || pendingSelection === null || pendingSelection.dirty) { const blockCursorElement = editor._blockCursorElement; if (blockCursorElement !== null) { - removeDOMBlockCursorElement( - blockCursorElement, - editor, - rootElement as HTMLElement, - ); + removeDOMBlockCursorElement(blockCursorElement, editor, rootElement); } updateDOMSelection( currentSelection, @@ -630,17 +628,13 @@ export function $commitPendingUpdates( editor, domSelection, tags, - rootElement as HTMLElement, + rootElement, nodeCount, ); } - updateDOMBlockCursorElement( - editor, - rootElement as HTMLElement, - pendingSelection, - ); + updateDOMBlockCursorElement(editor, rootElement, pendingSelection); if (observer !== null) { - observer.observe(rootElement as Node, observerOptions); + observer.observe(rootElement, observerOptions); } } finally { activeEditor = previousActiveEditor; @@ -1005,6 +999,7 @@ function $beginUpdate( const shouldUpdate = editor._dirtyType !== NO_DIRTY_NODES || + editor._deferred.length > 0 || editorStateHasDirtySelection(pendingEditorState, editor); if (shouldUpdate) { diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index f4cc88ac86a..de2e019dd20 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -72,7 +72,6 @@ import { internalGetActiveEditorState, isCurrentlyReadOnlyMode, triggerCommandListeners, - updateEditor, } from './LexicalUpdates'; export const emptyFunction = () => { @@ -115,9 +114,9 @@ export function $isSelectionCapturedInDecorator(node: Node): boolean { } export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean { - const activeElement = document.activeElement as HTMLElement; + const activeElement = document.activeElement; - if (activeElement === null) { + if (!isHTMLElement(activeElement)) { return false; } const nodeName = activeElement.nodeName; @@ -144,7 +143,7 @@ export function isSelectionWithinEditor( rootElement.contains(focusDOM) && // Ignore if selection is within nested editor anchorDOM !== null && - !isSelectionCapturedInDecoratorInput(anchorDOM as Node) && + !isSelectionCapturedInDecoratorInput(anchorDOM) && getNearestEditorFromDOMNode(anchorDOM) === editor ); } catch (error) { @@ -226,6 +225,15 @@ export function toggleTextFormatType( newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript; } else if (type === 'superscript') { newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript; + } else if (type === 'lowercase') { + newFormat &= ~TEXT_TYPE_TO_FORMAT.uppercase; + newFormat &= ~TEXT_TYPE_TO_FORMAT.capitalize; + } else if (type === 'uppercase') { + newFormat &= ~TEXT_TYPE_TO_FORMAT.lowercase; + newFormat &= ~TEXT_TYPE_TO_FORMAT.capitalize; + } else if (type === 'capitalize') { + newFormat &= ~TEXT_TYPE_TO_FORMAT.lowercase; + newFormat &= ~TEXT_TYPE_TO_FORMAT.uppercase; } return newFormat; } @@ -498,22 +506,31 @@ export function getEditorStateTextContent(editorState: EditorState): string { return editorState.read(() => $getRoot().getTextContent()); } -export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void { - // Mark all existing text nodes as dirty - updateEditor( - editor, +export function markNodesWithTypesAsDirty( + editor: LexicalEditor, + types: string[], +): void { + // We only need to mark nodes dirty if they were in the previous state. + // If they aren't, then they are by definition dirty already. + const cachedMap = getCachedTypeToNodeMap(editor.getEditorState()); + const dirtyNodeMaps: NodeMap[] = []; + for (const type of types) { + const nodeMap = cachedMap.get(type); + if (nodeMap) { + // By construction these are non-empty + dirtyNodeMaps.push(nodeMap); + } + } + // Nothing to mark dirty, no update necessary + if (dirtyNodeMaps.length === 0) { + return; + } + editor.update( () => { - const editorState = getActiveEditorState(); - if (editorState.isEmpty()) { - return; - } - if (type === 'root') { - $getRoot().markDirty(); - return; - } - const nodeMap = editorState._nodeMap; - for (const [, node] of nodeMap) { - node.markDirty(); + for (const nodeMap of dirtyNodeMaps) { + for (const node of nodeMap.values()) { + node.markDirty(); + } } }, editor._pendingEditorState === null @@ -1084,10 +1101,25 @@ export function isSelectAll( return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey); } -export function $selectAll(): void { +export function $selectAll(selection?: RangeSelection | null): RangeSelection { const root = $getRoot(); - const selection = root.select(0, root.getChildrenSize()); - $setSelection($normalizeSelection(selection)); + + if ($isRangeSelection(selection)) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = anchor.getNode(); + const topParent = anchorNode.getTopLevelElementOrThrow(); + const rootNode = topParent.getParentOrThrow(); + anchor.set(rootNode.getKey(), 0, 'element'); + focus.set(rootNode.getKey(), rootNode.getChildrenSize(), 'element'); + $normalizeSelection(selection); + return selection; + } else { + // Create a new RangeSelection + const newSelection = root.select(0, root.getChildrenSize()); + $setSelection($normalizeSelection(newSelection)); + return newSelection; + } } export function getCachedClassNameArray( @@ -1267,7 +1299,7 @@ export function getElementByKeyOrThrow( export function getParentElement(node: Node): HTMLElement | null { const parentElement = (node as HTMLSlotElement).assignedSlot || node.parentElement; - return parentElement !== null && parentElement.nodeType === 11 + return isDocumentFragment(parentElement) ? ((parentElement as unknown as ShadowRoot).host as HTMLElement) : parentElement; } @@ -1695,28 +1727,37 @@ export function $findMatchingParent( * @param x - The element being tested * @returns Returns true if x is an HTML anchor tag, false otherwise */ -export function isHTMLAnchorElement(x: Node): x is HTMLAnchorElement { +export function isHTMLAnchorElement(x: unknown): x is HTMLAnchorElement { return isHTMLElement(x) && x.tagName === 'A'; } /** - * @param x - The element being testing + * @param x - The element being tested * @returns Returns true if x is an HTML element, false otherwise. */ -export function isHTMLElement(x: Node | EventTarget): x is HTMLElement { - // @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors - return x.nodeType === 1; +export function isHTMLElement(x: unknown): x is HTMLElement { + return isDOMNode(x) && x.nodeType === 1; +} + +/** + * @param x - The element being tested + * @returns Returns true if x is a DOM Node, false otherwise. + */ +export function isDOMNode(x: unknown): x is Node { + return ( + typeof x === 'object' && + x !== null && + 'nodeType' in x && + typeof x.nodeType === 'number' + ); } /** * @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; +export function isDocumentFragment(x: unknown): x is DocumentFragment { + return isDOMNode(x) && x.nodeType === 11; } /** @@ -1810,17 +1851,26 @@ export function getCachedTypeToNodeMap( ); let typeToNodeMap = cachedNodeMaps.get(editorState); if (!typeToNodeMap) { - typeToNodeMap = new Map(); + typeToNodeMap = computeTypeToNodeMap(editorState); cachedNodeMaps.set(editorState, typeToNodeMap); - for (const [nodeKey, node] of editorState._nodeMap) { - const nodeType = node.__type; - let nodeMap = typeToNodeMap.get(nodeType); - if (!nodeMap) { - nodeMap = new Map(); - typeToNodeMap.set(nodeType, nodeMap); - } - nodeMap.set(nodeKey, node); + } + return typeToNodeMap; +} + +/** + * @internal + * Compute a Map of node type to nodes for an EditorState + */ +function computeTypeToNodeMap(editorState: EditorState): TypeToNodeMap { + const typeToNodeMap = new Map(); + for (const [nodeKey, node] of editorState._nodeMap) { + const nodeType = node.__type; + let nodeMap = typeToNodeMap.get(nodeType); + if (!nodeMap) { + nodeMap = new Map(); + typeToNodeMap.set(nodeType, nodeMap); } + nodeMap.set(nodeKey, node); } return typeToNodeMap; } diff --git a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx index cf33a568d3f..af71d8f681c 100644 --- a/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx +++ b/packages/lexical/src/__tests__/unit/LexicalEditor.test.tsx @@ -39,6 +39,7 @@ import { createEditor, EditorState, ElementNode, + getDOMSelection, type Klass, type LexicalEditor, type LexicalNode, @@ -2210,7 +2211,7 @@ describe('LexicalEditor tests', () => { await editor.update(() => { const root = $getRoot(); - const tableCell = $createTableCellNode(0); + const tableCell = $createTableCellNode(); const tableRow = $createTableRowNode(); const table = $createTableNode(); @@ -2225,7 +2226,7 @@ describe('LexicalEditor tests', () => { await editor.update(() => { const tableRow = $getNodeByKey(tableRowKey) as TableRowNode; - const tableCell = $createTableCellNode(0); + const tableCell = $createTableCellNode(); tableRow.append(tableCell); }); @@ -2893,4 +2894,94 @@ describe('LexicalEditor tests', () => { expect(onError).not.toHaveBeenCalled(); }); }); + + describe('selection', () => { + it('updates the DOM selection', async () => { + const onError = jest.fn(); + const newEditor = createTestEditor({ + onError: onError, + }); + const text = 'initial content'; + let textNode!: TextNode; + await newEditor.update( + () => { + textNode = $createTextNode(text); + $getRoot().append($createParagraphNode().append(textNode)); + textNode.select(); + }, + {tag: 'history-merge'}, + ); + await newEditor.setRootElement(container); + const domText = newEditor.getElementByKey(textNode.getKey()) + ?.firstChild as Text; + expect(domText).not.toBe(null); + let selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + let range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + await newEditor.update(() => { + textNode.select(0); + }); + selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + range = selection.getRangeAt(0); + expect(range.collapsed).toBe(false); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(0); + expect(range.endOffset).toBe(text.length); + expect(onError).not.toHaveBeenCalled(); + }); + it('does not update the Lexical->DOM selection with skip-dom-selection', async () => { + const onError = jest.fn(); + const newEditor = createTestEditor({ + onError: onError, + }); + const text = 'initial content'; + let textNode!: TextNode; + await newEditor.update( + () => { + textNode = $createTextNode(text); + $getRoot().append($createParagraphNode().append(textNode)); + textNode.select(); + }, + {tag: 'history-merge'}, + ); + await newEditor.setRootElement(container); + const domText = newEditor.getElementByKey(textNode.getKey()) + ?.firstChild as Text; + expect(domText).not.toBe(null); + let selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + let range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + await newEditor.update( + () => { + textNode.select(0); + }, + {tag: 'skip-dom-selection'}, + ); + selection = getDOMSelection(newEditor._window || window) as Selection; + expect(selection).not.toBe(null); + expect(selection.rangeCount > 0); + range = selection.getRangeAt(0); + expect(range.collapsed).toBe(true); + expect(range.startContainer).toBe(domText); + expect(range.endContainer).toBe(domText); + expect(range.startOffset).toBe(text.length); + expect(range.endOffset).toBe(text.length); + expect(onError).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts index e360eac2486..6b7e913c1ba 100644 --- a/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts +++ b/packages/lexical/src/__tests__/unit/LexicalUtils.test.ts @@ -244,6 +244,36 @@ describe('LexicalUtils tests', () => { }); describe('$onUpdate', () => { + test('deferred even when there are no dirty nodes', () => { + const {editor} = testEnv; + const runs: string[] = []; + + editor.update( + () => { + $onUpdate(() => { + runs.push('second'); + }); + }, + { + onUpdate: () => { + runs.push('first'); + }, + }, + ); + expect(runs).toEqual([]); + editor.update(() => { + $onUpdate(() => { + runs.push('third'); + }); + }); + expect(runs).toEqual([]); + + // Flush pending updates + editor.read(() => {}); + + expect(runs).toEqual(['first', 'second', 'third']); + }); + test('added fn runs after update, original onUpdate, and prior calls to $onUpdate', () => { const {editor} = testEnv; const runs: string[] = []; diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 562c7c4e387..0c7e1d7318b 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -29,6 +29,7 @@ export type { SerializedEditor, Spread, Transform, + UpdateListener, } from './LexicalEditor'; export type { EditorState, @@ -185,6 +186,7 @@ export { getNearestEditorFromDOMNode, isBlockDomNode, isDocumentFragment, + isDOMNode, isDOMTextNode, isDOMUnmanaged, isHTMLAnchorElement, diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 285a546545a..19e54b0cfa6 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -769,7 +769,7 @@ export class ElementNode extends LexicalNode { } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); - if (element && isHTMLElement(element)) { + if (isHTMLElement(element)) { const indent = this.getIndent(); if (indent > 0) { // padding-inline-start is not widely supported in email HTML diff --git a/packages/lexical/src/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index c1250aeae16..1fec9c8b257 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -140,7 +140,7 @@ export class ParagraphNode extends ElementNode { exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); - if (element && isHTMLElement(element)) { + if (isHTMLElement(element)) { if (this.isEmpty()) { element.append(document.createElement('br')); } diff --git a/packages/lexical/src/nodes/LexicalRootNode.ts b/packages/lexical/src/nodes/LexicalRootNode.ts index b99576b8ea4..7e4782061f1 100644 --- a/packages/lexical/src/nodes/LexicalRootNode.ts +++ b/packages/lexical/src/nodes/LexicalRootNode.ts @@ -77,7 +77,7 @@ export class RootNode extends ElementNode { // View - updateDOM(prevNode: RootNode, dom: HTMLElement): false { + updateDOM(prevNode: this, dom: HTMLElement): false { return false; } diff --git a/packages/lexical/src/nodes/LexicalTabNode.ts b/packages/lexical/src/nodes/LexicalTabNode.ts index d3182e40df0..8c5999b33c2 100644 --- a/packages/lexical/src/nodes/LexicalTabNode.ts +++ b/packages/lexical/src/nodes/LexicalTabNode.ts @@ -11,8 +11,9 @@ import type {DOMConversionMap, NodeKey} from '../LexicalNode'; import invariant from 'shared/invariant'; import {IS_UNMERGEABLE} from '../LexicalConstants'; +import {EditorConfig} from '../LexicalEditor'; import {LexicalNode} from '../LexicalNode'; -import {$applyNodeReplacement} from '../LexicalUtils'; +import {$applyNodeReplacement, getCachedClassNameArray} from '../LexicalUtils'; import { SerializedTextNode, TextDetailType, @@ -47,6 +48,17 @@ export class TabNode extends TextNode { return null; } + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + const classNames = getCachedClassNameArray(config.theme, 'tab'); + + if (classNames !== undefined) { + const domClassList = dom.classList; + domClassList.add(...classNames); + } + return dom; + } + static importJSON(serializedTabNode: SerializedTabNode): TabNode { const node = $createTabNode(); node.setFormat(serializedTabNode.format); diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index fad639a1c72..ed57345627f 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -90,7 +90,10 @@ export type TextFormatType = | 'highlight' | 'code' | 'subscript' - | 'superscript'; + | 'superscript' + | 'lowercase' + | 'uppercase' + | 'capitalize'; export type TextModeType = 'normal' | 'token' | 'segmented'; @@ -490,11 +493,7 @@ export class TextNode extends LexicalNode { return dom; } - updateDOM( - prevNode: TextNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { + updateDOM(prevNode: this, dom: HTMLElement, config: EditorConfig): boolean { const nextText = this.__text; const prevFormat = prevNode.__format; const nextFormat = this.__format; @@ -621,7 +620,7 @@ export class TextNode extends LexicalNode { exportDOM(editor: LexicalEditor): DOMExportOutput { let {element} = super.exportDOM(editor); invariant( - element !== null && isHTMLElement(element), + isHTMLElement(element), 'Expected TextNode createDOM to always return a HTMLElement', ); element.style.whiteSpace = 'pre-wrap'; diff --git a/packages/lexical/src/nodes/__tests__/unit/LexicalTextNode.test.tsx b/packages/lexical/src/nodes/__tests__/unit/LexicalTextNode.test.tsx index 37191abc831..358d2b657dd 100644 --- a/packages/lexical/src/nodes/__tests__/unit/LexicalTextNode.test.tsx +++ b/packages/lexical/src/nodes/__tests__/unit/LexicalTextNode.test.tsx @@ -32,13 +32,16 @@ import { } from '../../../__tests__/utils'; import { IS_BOLD, + IS_CAPITALIZE, IS_CODE, IS_HIGHLIGHT, IS_ITALIC, + IS_LOWERCASE, IS_STRIKETHROUGH, IS_SUBSCRIPT, IS_SUPERSCRIPT, IS_UNDERLINE, + IS_UPPERCASE, } from '../../../LexicalConstants'; import { $getCompositionKey, @@ -51,12 +54,15 @@ const editorConfig = Object.freeze({ theme: { text: { bold: 'my-bold-class', + capitalize: 'my-capitalize-class', code: 'my-code-class', highlight: 'my-highlight-class', italic: 'my-italic-class', + lowercase: 'my-lowercase-class', strikethrough: 'my-strikethrough-class', underline: 'my-underline-class', underlineStrikethrough: 'my-underline-strikethrough-class', + uppercase: 'my-uppercase-class', }, }, }); @@ -210,6 +216,9 @@ describe('LexicalTextNode tests', () => { ['subscript', IS_SUBSCRIPT], ['superscript', IS_SUPERSCRIPT], ['highlight', IS_HIGHLIGHT], + ['lowercase', IS_LOWERCASE], + ['uppercase', IS_UPPERCASE], + ['capitalize', IS_CAPITALIZE], ] as const)('%s flag', (formatFlag: TextFormatType, stateFormat: number) => { const flagPredicate = (node: TextNode) => node.hasFormat(formatFlag); const flagToggle = (node: TextNode) => node.toggleFormat(formatFlag); @@ -318,6 +327,34 @@ describe('LexicalTextNode tests', () => { }); }); + test('capitalization formats are mutually exclusive', async () => { + const capitalizationFormats: TextFormatType[] = [ + 'lowercase', + 'uppercase', + 'capitalize', + ]; + + await update(() => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Hello World'); + paragraphNode.append(textNode); + $getRoot().append(paragraphNode); + + // Set each format and ensure that the other formats are cleared + capitalizationFormats.forEach((formatToSet) => { + textNode.toggleFormat(formatToSet as TextFormatType); + + capitalizationFormats + .filter((format) => format !== formatToSet) + .forEach((format) => + expect(textNode.hasFormat(format as TextFormatType)).toBe(false), + ); + + expect(textNode.hasFormat(formatToSet as TextFormatType)).toBe(true); + }); + }); + }); + test('selectPrevious()', async () => { await update(() => { const paragraphNode = $createParagraphNode(); @@ -636,6 +673,24 @@ describe('LexicalTextNode tests', () => { 'My text node', 'My text node', ], + [ + 'lowercase', + IS_LOWERCASE, + 'My text node', + 'My text node', + ], + [ + 'uppercase', + IS_UPPERCASE, + 'My text node', + 'My text node', + ], + [ + 'capitalize', + IS_CAPITALIZE, + 'My text node', + 'My text node', + ], [ 'underline + strikethrough', IS_UNDERLINE | IS_STRIKETHROUGH, @@ -669,15 +724,16 @@ describe('LexicalTextNode tests', () => { 'My text node', ], [ - 'code + underline + strikethrough + bold + italic + highlight', + 'code + underline + strikethrough + bold + italic + highlight + uppercase', IS_CODE | IS_UNDERLINE | IS_STRIKETHROUGH | IS_BOLD | IS_ITALIC | - IS_HIGHLIGHT, + IS_HIGHLIGHT | + IS_UPPERCASE, 'My text node', - 'My text node', + 'My text node', ], ])('%s text format type', async (_type, format, contents, expectedHTML) => { await update(() => { diff --git a/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package-lock.json b/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package-lock.json index 113ad945203..c0bf25d4f21 100644 --- a/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package-lock.json +++ b/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package-lock.json @@ -1,24 +1,24 @@ { "name": "lexical-sveltekit-vanilla-js", - "version": "0.17.1", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lexical-sveltekit-vanilla-js", - "version": "0.17.1", + "version": "0.21.0", "devDependencies": { - "@lexical/dragon": "0.17.1", - "@lexical/history": "0.17.1", - "@lexical/rich-text": "0.17.1", - "@lexical/utils": "0.17.1", + "@lexical/dragon": "0.21.0", + "@lexical/history": "0.21.0", + "@lexical/rich-text": "0.21.0", + "@lexical/utils": "0.21.0", "@playwright/test": "^1.28.1", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.1", - "@sveltejs/kit": "^2.0.0", + "@sveltejs/kit": "^2.10.1", "@sveltejs/vite-plugin-svelte": "^3.0.0", - "lexical": "0.17.1", + "lexical": "0.21.0", "prettier": "^3.1.1", "prettier-plugin-svelte": "^3.1.2", "svelte": "^4.2.19", @@ -41,9 +41,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -57,9 +57,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -73,9 +73,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -89,9 +89,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -105,9 +105,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -121,9 +121,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -137,9 +137,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -153,9 +153,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -169,9 +169,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -185,9 +185,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -201,9 +201,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -217,9 +217,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -233,9 +233,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -249,9 +249,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -265,9 +265,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -281,9 +281,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -297,9 +297,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -313,9 +313,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -329,9 +329,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -361,9 +361,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -377,9 +377,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -393,9 +393,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -457,108 +457,100 @@ } }, "node_modules/@lexical/clipboard": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.17.1.tgz", - "integrity": "sha512-OVqnEfWX8XN5xxuMPo6BfgGKHREbz++D5V5ISOiml0Z8fV/TQkdgwqbBJcUdJHGRHWSUwdK7CWGs/VALvVvZyw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.21.0.tgz", + "integrity": "sha512-3lNMlMeUob9fcnRXGVieV/lmPbmet/SVWckNTOwzfKrZ/YW5HiiyJrWviLRVf50dGXTbmBGt7K/2pfPYvWCHFA==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/html": "0.17.1", - "@lexical/list": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/html": "0.21.0", + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/dragon": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.17.1.tgz", - "integrity": "sha512-lhBRKP7RlhiVCLtF0qiNqmMhEO6cQB43sMe7d4bvuY1G2++oKY/XAJPg6QJZdXRrCGRQ6vZ26QRNhRPmCxL5Ng==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.21.0.tgz", + "integrity": "sha512-ahTCaOtRFNauEzplN1qVuPjyGAlDd+XcVM5FQCdxVh/1DvqmBxEJRVuCBqatzUUVb89jRBekYUcEdnY9iNjvEQ==", "dev": true, - "license": "MIT", "dependencies": { - "lexical": "0.17.1" + "lexical": "0.21.0" } }, "node_modules/@lexical/history": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.17.1.tgz", - "integrity": "sha512-OU/ohajz4FXchUhghsWC7xeBPypFe50FCm5OePwo767G7P233IztgRKIng2pTT4zhCPW7S6Mfl53JoFHKehpWA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.21.0.tgz", + "integrity": "sha512-Sv2sici2NnAfHYHYRSjjS139MDT8fHP6PlYM2hVr+17dOg7/fJl22VBLRgQ7/+jLtAPxQjID69jvaMlOvt4Oog==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/html": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.17.1.tgz", - "integrity": "sha512-yGG+K2DXl7Wn2DpNuZ0Y3uCHJgfHkJN3/MmnFb4jLnH1FoJJiuy7WJb/BRRh9H+6xBJ9v70iv+kttDJ0u1xp5w==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.21.0.tgz", + "integrity": "sha512-UGahVsGz8OD7Ya39qwquE+JPStTxCw/uaQrnUNorCM7owtPidO2H+tsilAB3A1GK3ksFGdHeEjBjG0Gf7gOg+Q==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/list": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.17.1.tgz", - "integrity": "sha512-k9ZnmQuBvW+xVUtWJZwoGtiVG2cy+hxzkLGU4jTq1sqxRIoSeGcjvhFAK8JSEj4i21SgkB1FmkWXoYK5kbwtRA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.21.0.tgz", + "integrity": "sha512-WItGlwwNJCS8b6SO1QPKzArShmD+OXQkLbhBcAh+EfpnkvmCW5T5LqY+OfIRmEN1dhDOnwqCY7mXkivWO8o5tw==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/rich-text": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.17.1.tgz", - "integrity": "sha512-T3kvj4P1OpedX9jvxN3WN8NP1Khol6mCW2ScFIRNRz2dsXgyN00thH1Q1J/uyu7aKyGS7rzcY0rb1Pz1qFufqQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.21.0.tgz", + "integrity": "sha512-+pvEKUneEkGfWOSTl9jU58N9knePilMLxxOtppCAcgnaCdilOh3n5YyRppXhvmprUe0JaTseCMoik2LP51G/JA==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/clipboard": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/clipboard": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/selection": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.17.1.tgz", - "integrity": "sha512-qBKVn+lMV2YIoyRELNr1/QssXx/4c0id9NCB/BOuYlG8du5IjviVJquEF56NEv2t0GedDv4BpUwkhXT2QbNAxA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.21.0.tgz", + "integrity": "sha512-4u53bc8zlPPF0rnHjsGQExQ1St8NafsDd70/t1FMw7yvoMtUsKdH7+ap00esLkJOMv45unJD7UOzKRqU1X0sEA==", "dev": true, - "license": "MIT", "dependencies": { - "lexical": "0.17.1" + "lexical": "0.21.0" } }, "node_modules/@lexical/table": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.17.1.tgz", - "integrity": "sha512-2fUYPmxhyuMQX3MRvSsNaxbgvwGNJpHaKx1Ldc+PT2MvDZ6ALZkfsxbi0do54Q3i7dOon8/avRp4TuVaCnqvoA==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.21.0.tgz", + "integrity": "sha512-JhylAWcf4qKD4FmxMUt3YzH5zg2+baBr4+/haLZL7178hMvUzJwGIiWk+3hD3phzmW3WrP49uFXzM7DMSCkE8w==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/utils": "0.17.1", - "lexical": "0.17.1" + "@lexical/clipboard": "0.21.0", + "@lexical/utils": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@lexical/utils": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.17.1.tgz", - "integrity": "sha512-jCQER5EsvhLNxKH3qgcpdWj/necUb82Xjp8qWQ3c0tyL07hIRm2tDRA/s9mQmvcP855HEZSmGVmR5SKtkcEAVg==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.21.0.tgz", + "integrity": "sha512-YzsNOAiLkCy6R3DuP18gtseDrzgx+30lFyqRvp5M7mckeYgQElwdfG5biNFDLv7BM9GjSzgU5Cunjycsx6Sjqg==", "dev": true, - "license": "MIT", "dependencies": { - "@lexical/list": "0.17.1", - "@lexical/selection": "0.17.1", - "@lexical/table": "0.17.1", - "lexical": "0.17.1" + "@lexical/list": "0.21.0", + "@lexical/selection": "0.21.0", + "@lexical/table": "0.21.0", + "lexical": "0.21.0" } }, "node_modules/@playwright/test": { @@ -577,26 +569,27 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.25", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", - "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", "dev": true }, "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.7", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.7.tgz", - "integrity": "sha512-nEvcR+LRjEjsaSsc4x3XZfCCvZIaSMenZu/OiwOKGN2UhQpAYI7ru7czFvyWbErlpoGjnSX3D5Ch5FcMA3kRWQ==", + "version": "28.0.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", + "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", - "glob": "^8.0.3", + "fdir": "^6.2.0", "is-reference": "1.2.1", - "magic-string": "^0.30.3" + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0 || 14 >= 14.17" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" @@ -607,6 +600,32 @@ } } }, + "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", + "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "dev": true, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", @@ -628,15 +647,14 @@ } }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.0.tgz", + "integrity": "sha512-9eO5McEICxMzJpDW9OnMYSv4Sta3hmt7VtBFz5zR9273suNOydOyq/FrGeGy+KsTRFm8w0SLVhzig2ILFT63Ag==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", "is-module": "^1.0.0", "resolve": "^1.22.1" }, @@ -675,9 +693,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz", - "integrity": "sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz", + "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==", "cpu": [ "arm" ], @@ -688,9 +706,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz", - "integrity": "sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz", + "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==", "cpu": [ "arm64" ], @@ -701,9 +719,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz", - "integrity": "sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz", + "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==", "cpu": [ "arm64" ], @@ -714,9 +732,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz", - "integrity": "sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz", + "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==", "cpu": [ "x64" ], @@ -726,10 +744,49 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz", + "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz", + "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz", - "integrity": "sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz", + "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz", + "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==", "cpu": [ "arm" ], @@ -740,9 +797,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz", - "integrity": "sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz", + "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==", "cpu": [ "arm64" ], @@ -753,9 +810,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz", - "integrity": "sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz", + "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==", "cpu": [ "arm64" ], @@ -765,10 +822,36 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz", + "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz", + "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz", - "integrity": "sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz", + "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==", "cpu": [ "riscv64" ], @@ -778,10 +861,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz", + "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz", - "integrity": "sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz", + "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==", "cpu": [ "x64" ], @@ -792,9 +888,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz", - "integrity": "sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz", + "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==", "cpu": [ "x64" ], @@ -805,9 +901,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz", - "integrity": "sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz", + "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==", "cpu": [ "arm64" ], @@ -818,9 +914,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz", - "integrity": "sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz", + "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==", "cpu": [ "ia32" ], @@ -831,9 +927,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz", - "integrity": "sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz", + "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==", "cpu": [ "x64" ], @@ -844,26 +940,26 @@ ] }, "node_modules/@sveltejs/adapter-auto": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", - "integrity": "sha512-6LeZft2Fo/4HfmLBi5CucMYmgRxgcETweQl/yQoZo/895K3S9YWYN4Sfm/IhwlIpbJp3QNvhKmwCHbsqQNYQpw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", + "integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==", "dev": true, "dependencies": { - "import-meta-resolve": "^4.0.0" + "import-meta-resolve": "^4.1.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/adapter-node": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", - "integrity": "sha512-eYdmxdUWMW+dad1JfMsWBPY2vjXz9eE+52A2AQnXPScPJlIxIVk5mmbaEEzrZivLfO2wEcLTZ5vdC03W69x+iA==", + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.9.tgz", + "integrity": "sha512-51euNrx0AcaTu8//wDfVh7xmqQSVgU52rfinE/MwvGkJa4nHPJMHmzv6+OIpmxg7gZaF6+5NVlxnieCzxLD59g==", "dev": true, "dependencies": { - "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-node-resolve": "^15.3.0", "rollup": "^4.9.5" }, "peerDependencies": { @@ -871,32 +967,32 @@ } }, "node_modules/@sveltejs/adapter-static": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.1.tgz", - "integrity": "sha512-6lMvf7xYEJ+oGeR5L8DFJJrowkefTK6ZgA4JiMqoClMkKq0s6yvsd3FZfCFvX1fQ0tpCD7fkuRVHsnUVgsHyNg==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.6.tgz", + "integrity": "sha512-MGJcesnJWj7FxDcB/GbrdYD3q24Uk0PIL4QIX149ku+hlJuj//nxUbb0HxUTpjkecWfHjVveSUnUaQWnPRXlpg==", "dev": true, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.4.tgz", - "integrity": "sha512-eDxK2d4EGzk99QsZNoPXe7jlzA5EGqfcCpUwZ912bhnalsZ2ZsG5wGRthkydupVjYyqdmzEanVKFhLxU2vkPSQ==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.10.1.tgz", + "integrity": "sha512-2aormKTn94aU8Lfxj4gcbRGh1Dyw0hCFlNo51+njdRDn9P2ERuWC4bOtTuoy5HJpPYR3AH8oaaEjKDWUHbi1OA==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^4.3.2", - "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", + "devalue": "^5.1.0", + "esm-env": "^1.2.1", + "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", + "sirv": "^3.0.0", "tiny-glob": "^0.2.9" }, "bin": { @@ -906,9 +1002,9 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" + "vite": "^5.0.3 || ^6.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { @@ -957,9 +1053,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, "node_modules/@types/resolve": { @@ -998,33 +1094,6 @@ "dequal": "^2.0.3" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/code-red": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", @@ -1111,15 +1180,15 @@ } }, "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -1129,35 +1198,35 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", + "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==", "dev": true }, "node_modules/estree-walker": { @@ -1166,12 +1235,6 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "dev": true }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -1195,37 +1258,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/globalyzer": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", @@ -1251,53 +1283,25 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, "dependencies": { - "builtin-modules": "^3.3.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1328,11 +1332,10 @@ } }, "node_modules/lexical": { - "version": "0.17.1", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.17.1.tgz", - "integrity": "sha512-72/MhR7jqmyqD10bmJw8gztlCm4KDDT+TPtU4elqXrEvHoO5XENi34YAEUD9gIkPfqSwyLa9mwAX1nKzIr5xEA==", - "dev": true, - "license": "MIT" + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.21.0.tgz", + "integrity": "sha512-Dxc5SCG4kB+wF+Rh55ism3SuecOKeOtCtGHFGKd6pj2QKVojtjkxGTQPMt7//2z5rMSue4R+hmRM0pCEZflupA==", + "dev": true }, "node_modules/locate-character": { "version": "3.0.0", @@ -1383,9 +1386,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -1400,15 +1403,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1445,9 +1439,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { @@ -1493,9 +1487,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "dev": true, "funding": [ { @@ -1513,8 +1507,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1563,12 +1557,12 @@ } }, "node_modules/rollup": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.13.0.tgz", - "integrity": "sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==", + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz", + "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.6" }, "bin": { "rollup": "dist/bin/rollup" @@ -1578,19 +1572,25 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.13.0", - "@rollup/rollup-android-arm64": "4.13.0", - "@rollup/rollup-darwin-arm64": "4.13.0", - "@rollup/rollup-darwin-x64": "4.13.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.13.0", - "@rollup/rollup-linux-arm64-gnu": "4.13.0", - "@rollup/rollup-linux-arm64-musl": "4.13.0", - "@rollup/rollup-linux-riscv64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-gnu": "4.13.0", - "@rollup/rollup-linux-x64-musl": "4.13.0", - "@rollup/rollup-win32-arm64-msvc": "4.13.0", - "@rollup/rollup-win32-ia32-msvc": "4.13.0", - "@rollup/rollup-win32-x64-msvc": "4.13.0", + "@rollup/rollup-android-arm-eabi": "4.28.1", + "@rollup/rollup-android-arm64": "4.28.1", + "@rollup/rollup-darwin-arm64": "4.28.1", + "@rollup/rollup-darwin-x64": "4.28.1", + "@rollup/rollup-freebsd-arm64": "4.28.1", + "@rollup/rollup-freebsd-x64": "4.28.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.28.1", + "@rollup/rollup-linux-arm-musleabihf": "4.28.1", + "@rollup/rollup-linux-arm64-gnu": "4.28.1", + "@rollup/rollup-linux-arm64-musl": "4.28.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.28.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1", + "@rollup/rollup-linux-riscv64-gnu": "4.28.1", + "@rollup/rollup-linux-s390x-gnu": "4.28.1", + "@rollup/rollup-linux-x64-gnu": "4.28.1", + "@rollup/rollup-linux-x64-musl": "4.28.1", + "@rollup/rollup-win32-arm64-msvc": "4.28.1", + "@rollup/rollup-win32-ia32-msvc": "4.28.1", + "@rollup/rollup-win32-x64-msvc": "4.28.1", "fsevents": "~2.3.2" } }, @@ -1613,9 +1613,9 @@ "dev": true }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -1623,13 +1623,13 @@ "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" + "node": ">=18" } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -1742,14 +1742,14 @@ } }, "node_modules/vite": { - "version": "5.2.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", - "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -1768,6 +1768,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -1785,6 +1786,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, @@ -1823,12 +1827,6 @@ "optional": true } } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true } } } diff --git a/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package.json b/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package.json index 79ae305e4b2..acb21924aa5 100644 --- a/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package.json +++ b/scripts/__tests__/integration/fixtures/lexical-esm-sveltekit-vanilla-js/package.json @@ -17,7 +17,7 @@ "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-node": "^5.0.1", "@sveltejs/adapter-static": "^3.0.1", - "@sveltejs/kit": "^2.0.0", + "@sveltejs/kit": "^2.10.1", "@sveltejs/vite-plugin-svelte": "^3.0.0", "lexical": "0.21.0", "prettier": "^3.1.1",