From 29871f1d390032921442cd8ec954d444fd994c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Germ=C3=A1n=20Jablo=C3=B1ski?= <43938777+GermanJablo@users.noreply.github.com> Date: Sat, 12 Oct 2024 13:30:17 -0300 Subject: [PATCH 01/28] [lexical][lexical-list][lexical-rich-text]: Fix: Preserve indentation when serializing to and from HTML (#6693) Co-authored-by: Bob Ippolito --- packages/lexical-list/src/LexicalListNode.ts | 2 +- .../__tests__/unit/docSerialization.test.ts | 175 +++++++++++++++++- packages/lexical-rich-text/src/index.ts | 3 + packages/lexical/src/LexicalUtils.ts | 9 + packages/lexical/src/index.ts | 1 + .../lexical/src/nodes/LexicalElementNode.ts | 27 ++- .../lexical/src/nodes/LexicalParagraphNode.ts | 12 +- 7 files changed, 215 insertions(+), 14 deletions(-) diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts index 680e27aa899..45a87f30d2a 100644 --- a/packages/lexical-list/src/LexicalListNode.ts +++ b/packages/lexical-list/src/LexicalListNode.ts @@ -155,7 +155,7 @@ export class ListNode extends ElementNode { } exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); + const element = this.createDOM(editor._config, editor); if (element && isHTMLElement(element)) { if (this.__start !== 1) { element.setAttribute('start', String(this.__start)); diff --git a/packages/lexical-playground/__tests__/unit/docSerialization.test.ts b/packages/lexical-playground/__tests__/unit/docSerialization.test.ts index 41e697d30ce..8b9e73a6de1 100644 --- a/packages/lexical-playground/__tests__/unit/docSerialization.test.ts +++ b/packages/lexical-playground/__tests__/unit/docSerialization.test.ts @@ -15,7 +15,13 @@ */ import {serializedDocumentFromEditorState} from '@lexical/file'; -import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; +import {$generateHtmlFromNodes, $generateNodesFromDOM} from '@lexical/html'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $insertNodes, +} from 'lexical'; import {initializeUnitTest} from 'lexical/src/__tests__/utils'; import {docFromHash, docToHash} from '../../src/utils/docSerialization'; @@ -48,5 +54,172 @@ describe('docSerialization', () => { expect(await docFromHash(await docToHash(doc))).toEqual(doc); }); }); + + describe('Preserve indent serializing HTML <-> Lexical', () => { + it('preserves indentation', async () => { + const {editor} = testEnv; + const parser = new DOMParser(); + const htmlString = `

+ paragraph +

+

+ heading +

+
+ quote +
+

+ paragraph +

+

+ heading +

+
+ quote +
`; + const dom = parser.parseFromString(htmlString, 'text/html'); + await editor.update(() => { + const nodes = $generateNodesFromDOM(editor, dom); + $getRoot().select(); + $insertNodes(nodes); + }); + + const expectedEditorState = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'paragraph', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'heading', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + tag: 'h1', + type: 'heading', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'quote', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'quote', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'paragraph', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + textFormat: 0, + textStyle: '', + type: 'paragraph', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'heading', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + tag: 'h1', + type: 'heading', + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: 'normal', + style: '', + text: 'quote', + type: 'text', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 2, + type: 'quote', + version: 1, + }, + ], + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + }; + + const editorState = editor.getEditorState().toJSON(); + expect(editorState).toEqual(expectedEditorState); + let htmlString2; + await editor.update(() => { + htmlString2 = $generateHtmlFromNodes(editor); + }); + expect(htmlString2).toBe( + '

paragraph

heading

quote

paragraph

heading

quote
', + ); + }); + }); }); }); diff --git a/packages/lexical-rich-text/src/index.ts b/packages/lexical-rich-text/src/index.ts index fbf9f53b0ec..bf53a8acdd4 100644 --- a/packages/lexical-rich-text/src/index.ts +++ b/packages/lexical-rich-text/src/index.ts @@ -92,6 +92,7 @@ import { PASTE_COMMAND, REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, + setNodeIndentFromDOM, } from 'lexical'; import caretFromPoint from 'shared/caretFromPoint'; import { @@ -415,6 +416,7 @@ function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { ) { node = $createHeadingNode(nodeName); if (element.style !== null) { + setNodeIndentFromDOM(element, node); node.setFormat(element.style.textAlign as ElementFormatType); } } @@ -425,6 +427,7 @@ function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { const node = $createQuoteNode(); if (element.style !== null) { node.setFormat(element.style.textAlign as ElementFormatType); + setNodeIndentFromDOM(element, node); } return {node}; } diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index 472703d63d5..9039a186c50 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -1820,3 +1820,12 @@ export function $cloneWithProperties(latestNode: T): T { } return mutableNode; } + +export function setNodeIndentFromDOM( + elementDom: HTMLElement, + elementNode: ElementNode, +) { + const indentSize = parseInt(elementDom.style.paddingInlineStart, 10) || 0; + const indent = indentSize / 40; + elementNode.setIndent(indent); +} diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index b3f5013cdd7..538440b8195 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -187,6 +187,7 @@ export { isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, resetRandomKey, + setNodeIndentFromDOM, } from './LexicalUtils'; export {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; export {$isDecoratorNode, DecoratorNode} from './nodes/LexicalDecoratorNode'; diff --git a/packages/lexical/src/nodes/LexicalElementNode.ts b/packages/lexical/src/nodes/LexicalElementNode.ts index 474d0b405ba..65bc77aec5a 100644 --- a/packages/lexical/src/nodes/LexicalElementNode.ts +++ b/packages/lexical/src/nodes/LexicalElementNode.ts @@ -6,13 +6,17 @@ * */ -import type {NodeKey, SerializedLexicalNode} from '../LexicalNode'; +import type { + DOMExportOutput, + NodeKey, + SerializedLexicalNode, +} from '../LexicalNode'; import type { BaseSelection, PointType, RangeSelection, } from '../LexicalSelection'; -import type {KlassConstructor, Spread} from 'lexical'; +import type {KlassConstructor, LexicalEditor, Spread} from 'lexical'; import invariant from 'shared/invariant'; @@ -33,6 +37,7 @@ import {errorOnReadOnly, getActiveEditor} from '../LexicalUpdates'; import { $getNodeByKey, $isRootOrShadowRoot, + isHTMLElement, removeFromParent, } from '../LexicalUtils'; @@ -523,6 +528,24 @@ export class ElementNode extends LexicalNode { return writableSelf; } + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + if (element && isHTMLElement(element)) { + const indent = this.getIndent(); + if (indent > 0) { + // padding-inline-start is not widely supported in email HTML + // (see https://www.caniemail.com/features/css-padding-inline-start-end/), + // If you want to use HTML output for email, consider overriding the serialization + // to use `padding-right` in RTL languages, `padding-left` in `LTR` languages, or + // `text-indent` if you are ok with first-line indents. + // We recommend keeping multiples of 40px to maintain consistency with list-items + // (see https://github.com/facebook/lexical/pull/4025) + element.style.paddingInlineStart = `${indent * 40}px`; + } + } + + return {element}; + } // JSON serialization exportJSON(): SerializedElementNode { return { diff --git a/packages/lexical/src/nodes/LexicalParagraphNode.ts b/packages/lexical/src/nodes/LexicalParagraphNode.ts index ebbf9814581..c1250aeae16 100644 --- a/packages/lexical/src/nodes/LexicalParagraphNode.ts +++ b/packages/lexical/src/nodes/LexicalParagraphNode.ts @@ -30,6 +30,7 @@ import { $applyNodeReplacement, getCachedClassNameArray, isHTMLElement, + setNodeIndentFromDOM, toggleTextFormatType, } from '../LexicalUtils'; import {ElementNode} from './LexicalElementNode'; @@ -151,12 +152,6 @@ export class ParagraphNode extends ElementNode { if (direction) { element.dir = direction; } - const indent = this.getIndent(); - if (indent > 0) { - // padding-inline-start is not widely supported in email HTML, but - // Lexical Reconciler uses padding-inline-start. Using text-indent instead. - element.style.textIndent = `${indent * 20}px`; - } } return { @@ -229,10 +224,7 @@ function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { const node = $createParagraphNode(); if (element.style) { node.setFormat(element.style.textAlign as ElementFormatType); - const indent = parseInt(element.style.textIndent, 10) / 20; - if (indent > 0) { - node.setIndent(indent); - } + setNodeIndentFromDOM(element, node); } return {node}; } From 7f70ee40871d121f46576af6bbc6fff9045c1a9d Mon Sep 17 00:00:00 2001 From: Vadim Nicolaev Date: Sat, 12 Oct 2024 19:47:22 +0300 Subject: [PATCH 02/28] fix: preserve custom fields in Lexical-Yjs sync (#6724) --- packages/lexical-yjs/src/SyncCursors.ts | 1 + packages/lexical-yjs/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/lexical-yjs/src/SyncCursors.ts b/packages/lexical-yjs/src/SyncCursors.ts index bd4837dcf5d..a34c7538409 100644 --- a/packages/lexical-yjs/src/SyncCursors.ts +++ b/packages/lexical-yjs/src/SyncCursors.ts @@ -525,6 +525,7 @@ export function syncLexicalSelectionToYjs( shouldUpdatePosition(currentFocusPos, focusPos) ) { awareness.setLocalState({ + ...localState, anchorPos, awarenessData, color, diff --git a/packages/lexical-yjs/src/index.ts b/packages/lexical-yjs/src/index.ts index c06a6f06cb6..62b5fbbe8fc 100644 --- a/packages/lexical-yjs/src/index.ts +++ b/packages/lexical-yjs/src/index.ts @@ -20,6 +20,7 @@ export type UserState = { focusPos: null | RelativePosition; name: string; awarenessData: object; + [key: string]: unknown; }; export const CONNECTED_COMMAND: LexicalCommand = createCommand('CONNECTED_COMMAND'); From 2e64bfc5ca1b08c6636d121b77e2d1e733654d66 Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Sun, 13 Oct 2024 02:26:49 +0100 Subject: [PATCH 03/28] [lexical-playground] Remove unused command (#6726) --- .../lexical-playground/src/plugins/CollapsiblePlugin/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/lexical-playground/src/plugins/CollapsiblePlugin/index.ts b/packages/lexical-playground/src/plugins/CollapsiblePlugin/index.ts index 346053dc6ca..ebed3126590 100644 --- a/packages/lexical-playground/src/plugins/CollapsiblePlugin/index.ts +++ b/packages/lexical-playground/src/plugins/CollapsiblePlugin/index.ts @@ -28,7 +28,6 @@ import { KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, LexicalNode, - NodeKey, } from 'lexical'; import {useEffect} from 'react'; @@ -49,7 +48,6 @@ import { } from './CollapsibleTitleNode'; export const INSERT_COLLAPSIBLE_COMMAND = createCommand(); -export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand(); export default function CollapsiblePlugin(): null { const [editor] = useLexicalComposerContext(); From 8e9763be078f1098522cb13e20ecf7bbbbfd641a Mon Sep 17 00:00:00 2001 From: Bedru Umer <63902795+bedre7@users.noreply.github.com> Date: Tue, 15 Oct 2024 12:24:01 +0300 Subject: [PATCH 04/28] [lexical-playground] Bug Fix: match toolbar font size input with the rest of toolbar items in Read-Only mode (#6698) --- .../src/plugins/ToolbarPlugin/fontSize.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.css b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.css index 42178b07473..4223200b82f 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.css +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/fontSize.css @@ -19,6 +19,11 @@ width: 20px; } +.font-size-input:disabled { + opacity: 0.2; + cursor: not-allowed; +} + input[type='number']::-webkit-outer-spin-button, input[type='number']::-webkit-inner-spin-button { -webkit-appearance: none; From 648ddefcc8e6959c1cd8d685be317be3f85114f0 Mon Sep 17 00:00:00 2001 From: Sherry Date: Tue, 15 Oct 2024 15:59:10 +0200 Subject: [PATCH 05/28] Chore: add workflow to auto close stale pr based on label (#6732) --- .github/workflows/close-stale-pr.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .github/workflows/close-stale-pr.yml diff --git a/.github/workflows/close-stale-pr.yml b/.github/workflows/close-stale-pr.yml new file mode 100644 index 00000000000..43a213adc17 --- /dev/null +++ b/.github/workflows/close-stale-pr.yml @@ -0,0 +1,18 @@ +name: Close stale PR +on: + pull_request: + types: labeled +jobs: + close-pr: + if: github.event.label.name == 'stale-pr' + permissions: + pull-requests: write + runs-on: ubuntu-latest + steps: + - run: gh pr close "$NUMBER" --comment "$COMMENT" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + NUMBER: ${{ github.event.number }} + COMMENT: > + Closing this PR due to staleness! If there are new updates, please reopen the PR. From 2c9b03c86e5411a7160a793cb1a8b28f2a243e93 Mon Sep 17 00:00:00 2001 From: cwstra Date: Wed, 16 Oct 2024 00:02:45 +0000 Subject: [PATCH 06/28] [lexical-table] Bug Fix: `colWidths` not imported from DOM for TableNode (#6731) --- .../html/TablesHTMLCopyAndPaste.spec.mjs | 6 +++--- packages/lexical-table/src/LexicalTableNode.ts | 16 ++++++++++++++++ .../src/__tests__/unit/LexicalTableNode.test.tsx | 4 ++-- 3 files changed, 21 insertions(+), 5 deletions(-) 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 efe46564db1..520b08552c5 100644 --- a/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/CopyAndPaste/html/TablesHTMLCopyAndPaste.spec.mjs @@ -119,9 +119,9 @@ test.describe('HTML Tables CopyAndPaste', () => { html` - - - + + + '; expect(testEnv.innerHTML).toBe( - `
diff --git a/packages/lexical-table/src/LexicalTableNode.ts b/packages/lexical-table/src/LexicalTableNode.ts index 90031636d26..be988c5af96 100644 --- a/packages/lexical-table/src/LexicalTableNode.ts +++ b/packages/lexical-table/src/LexicalTableNode.ts @@ -29,6 +29,7 @@ import { ElementNode, } from 'lexical'; +import {PIXEL_VALUE_REG_EXP} from './constants'; import {$isTableCellNode, TableCellNode} from './LexicalTableCellNode'; import {TableDOMCell, TableDOMTable} from './LexicalTableObserver'; import {TableRowNode} from './LexicalTableRowNode'; @@ -358,6 +359,21 @@ export function $convertTableElement( if (domNode.hasAttribute('data-lexical-row-striping')) { tableNode.setRowStriping(true); } + const colGroup = domNode.querySelector(':scope > colgroup'); + 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; + } + columns.push(parseFloat(width)); + } + if (columns) { + tableNode.setColWidths(columns); + } + } return {node: tableNode}; } diff --git a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx index fb1d9af2332..570ac0e9ed2 100644 --- a/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx +++ b/packages/lexical-table/src/__tests__/unit/LexicalTableNode.test.tsx @@ -114,7 +114,7 @@ describe('LexicalTableNode tests', () => { const dataTransfer = new DataTransferMock(); dataTransfer.setData( 'text/html', - '

Hello there

General Kenobi!

Lexical is nice


', + '

Hello there

General Kenobi!

Lexical is nice


', ); await editor.update(() => { const selection = $getSelection(); @@ -127,7 +127,7 @@ describe('LexicalTableNode tests', () => { // Make sure paragraph is inserted inside empty cells const emptyCell = '


${emptyCell}

Hello there

General Kenobi!

Lexical is nice

`, + `${emptyCell}

Hello there

General Kenobi!

Lexical is nice

`, ); }); From 7a80c89b97d4d21903ea1baa8ea51046673815df Mon Sep 17 00:00:00 2001 From: Taro Shono Date: Wed, 16 Oct 2024 11:25:49 +0900 Subject: [PATCH 07/28] [lexical] Bug Fix: lines were being deleted with `deleteLine` (#6719) Co-authored-by: Ivaylo Pavlov Co-authored-by: Bob Ippolito --- .../__tests__/e2e/Selection.spec.mjs | 48 +++++++++++++++---- packages/lexical/src/LexicalSelection.ts | 10 ++-- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index 5addcdfbc1a..d458cb1ea6a 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -208,20 +208,20 @@ test.describe.parallel('Selection', () => {

`, ]; + const empty = html` +


+ `; await deleteLine(); - await assertHTML(page, lines.slice(0, 3).join('')); + await assertHTML(page, [lines[0], lines[1], lines[2], empty].join('')); + await page.keyboard.press('Backspace'); await deleteLine(); - await assertHTML(page, lines.slice(0, 2).join('')); + await assertHTML(page, [lines[0], lines[1]].join('')); await deleteLine(); - await assertHTML(page, lines.slice(0, 1).join('')); + await assertHTML(page, [lines[0], empty].join('')); + await page.keyboard.press('Backspace'); await deleteLine(); - await assertHTML( - page, - html` -


- `, - ); + await assertHTML(page, empty); }); test('can delete line which ends with element with CMD+delete', async ({ @@ -251,6 +251,7 @@ test.describe.parallel('Selection', () => { }; await deleteLine(); + await page.keyboard.press('Backspace'); await assertHTML( page, html` @@ -261,6 +262,7 @@ test.describe.parallel('Selection', () => {

`, ); + await page.keyboard.press('Backspace'); await deleteLine(); await assertHTML( page, @@ -270,6 +272,34 @@ test.describe.parallel('Selection', () => { ); }); + test('can delete line by collapse', async ({page, isPlainText}) => { + test.skip(isPlainText || !IS_MAC); + await focusEditor(page); + await insertCollapsible(page); + await page.keyboard.type('text'); + await page.keyboard.press('Enter'); + await page.keyboard.press('ArrowUp'); + + const deleteLine = async () => { + await keyDownCtrlOrMeta(page); + await page.keyboard.press('Backspace'); + await keyUpCtrlOrMeta(page); + }; + await deleteLine(); + await assertHTML( + page, + html` +

+ text +

+


+


+ `, + ); + }); + test('Can insert inline element within text and put selection after it', async ({ page, isPlainText, diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index e59cab5c83e..3af4b30d0db 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1856,11 +1856,11 @@ export class RangeSelection implements BaseSelection { this.modify('extend', isBackward, 'lineboundary'); - // If selection is extended to cover text edge then extend it one character more - // to delete its parent element. Otherwise text content will be deleted but empty - // parent node will remain - const endPoint = isBackward ? this.focus : this.anchor; - if (endPoint.offset === 0) { + // If the selection starts at the beginning of a text node (offset 0), + // extend the selection by one character in the specified direction. + // This ensures that the parent element is deleted along with its content. + // Otherwise, only the text content will be deleted, leaving an empty parent node. + if (this.isCollapsed() && this.anchor.offset === 0) { this.modify('extend', isBackward, 'character'); } From 95b8e87cccbf5991bca7110b7579b71ce45b3e73 Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Wed, 16 Oct 2024 03:28:54 +0100 Subject: [PATCH 08/28] [lexical-playground] Table Hover Actions Layout Fixes (#6725) --- packages/lexical-playground/src/Editor.tsx | 2 +- .../plugins/TableHoverActionsPlugin/index.tsx | 52 +++++++++++++------ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/lexical-playground/src/Editor.tsx b/packages/lexical-playground/src/Editor.tsx index 7fb6ae5c4a1..9d03a71c21e 100644 --- a/packages/lexical-playground/src/Editor.tsx +++ b/packages/lexical-playground/src/Editor.tsx @@ -183,7 +183,6 @@ export default function Editor(): JSX.Element { hasCellBackgroundColor={tableCellBackgroundColor} /> - @@ -213,6 +212,7 @@ export default function Editor(): JSX.Element { anchorElem={floatingAnchorElem} cellMerge={true} /> + (false); const [position, setPosition] = useState({}); - const codeSetRef = useRef>(new Set()); - const tableDOMNodeRef = useRef(null); + const tableSetRef = useRef>(new Set()); + const tableCellDOMNodeRef = useRef(null); const debouncedOnMouseMove = useDebounce( (event: MouseEvent) => { @@ -56,7 +56,7 @@ function TableHoverActionsContainer({ return; } - tableDOMNodeRef.current = tableDOMNode; + tableCellDOMNodeRef.current = tableDOMNode; let hoveredRowNode: TableCellNode | null = null; let hoveredColumnNode: TableCellNode | null = null; @@ -98,20 +98,21 @@ function TableHoverActionsContainer({ const { width: tableElemWidth, y: tableElemY, - x: tableElemX, right: tableElemRight, + left: tableElemLeft, bottom: tableElemBottom, height: tableElemHeight, } = (tableDOMElement as HTMLTableElement).getBoundingClientRect(); - const {y: editorElemY} = anchorElem.getBoundingClientRect(); + const {y: editorElemY, left: editorElemLeft} = + anchorElem.getBoundingClientRect(); if (hoveredRowNode) { setShownColumn(false); setShownRow(true); setPosition({ height: BUTTON_WIDTH_PX, - left: tableElemX, + left: tableElemLeft - editorElemLeft, top: tableElemBottom - editorElemY + 5, width: tableElemWidth, }); @@ -120,7 +121,7 @@ function TableHoverActionsContainer({ setShownRow(false); setPosition({ height: tableElemHeight, - left: tableElemRight + 5, + left: tableElemRight - editorElemLeft + 5, top: tableElemY - editorElemY, width: BUTTON_WIDTH_PX, }); @@ -131,6 +132,15 @@ function TableHoverActionsContainer({ 250, ); + // Hide the buttons on any table dimensions change to prevent last row cells + // overlap behind the 'Add Row' button when text entry changes cell height + const tableResizeObserver = useMemo(() => { + return new ResizeObserver(() => { + setShownRow(false); + setShownColumn(false); + }); + }, []); + useEffect(() => { if (!shouldListenMouseMove) { return; @@ -153,15 +163,27 @@ function TableHoverActionsContainer({ (mutations) => { editor.getEditorState().read(() => { for (const [key, type] of mutations) { + const tableDOMElement = editor.getElementByKey(key); switch (type) { case 'created': - codeSetRef.current.add(key); - setShouldListenMouseMove(codeSetRef.current.size > 0); + tableSetRef.current.add(key); + setShouldListenMouseMove(tableSetRef.current.size > 0); + if (tableDOMElement) { + tableResizeObserver.observe(tableDOMElement); + } break; case 'destroyed': - codeSetRef.current.delete(key); - setShouldListenMouseMove(codeSetRef.current.size > 0); + tableSetRef.current.delete(key); + setShouldListenMouseMove(tableSetRef.current.size > 0); + // Reset resize observers + tableResizeObserver.disconnect(); + tableSetRef.current.forEach((tableKey: NodeKey) => { + const tableElement = editor.getElementByKey(tableKey); + if (tableElement) { + tableResizeObserver.observe(tableElement); + } + }); break; default: @@ -173,13 +195,13 @@ function TableHoverActionsContainer({ {skipInitialization: false}, ), ); - }, [editor]); + }, [editor, tableResizeObserver]); const insertAction = (insertRow: boolean) => { editor.update(() => { - if (tableDOMNodeRef.current) { + if (tableCellDOMNodeRef.current) { const maybeTableNode = $getNearestNodeFromDOMNode( - tableDOMNodeRef.current, + tableCellDOMNodeRef.current, ); maybeTableNode?.selectEnd(); if (insertRow) { From 70de1d04c58a1b4a4149eb2dee385120e3fa0e81 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 16 Oct 2024 05:43:29 -0700 Subject: [PATCH 09/28] [*] Chore: Disable react-beta test job for now (#6738) --- .github/workflows/call-e2e-all-tests.yml | 33 ++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/.github/workflows/call-e2e-all-tests.yml b/.github/workflows/call-e2e-all-tests.yml index 3bb173e122d..bd54356729c 100644 --- a/.github/workflows/call-e2e-all-tests.yml +++ b/.github/workflows/call-e2e-all-tests.yml @@ -140,22 +140,23 @@ jobs: editor-mode: ${{ matrix.editor-mode }} events-mode: ${{ matrix.events-mode }} - react-beta: - strategy: - matrix: - # Currently using a single combination for every-patch e2e tests of - # react beta to reduce cost impact - editor-mode: ['rich-text'] - prod: [false] - uses: ./.github/workflows/call-e2e-test.yml - with: - os: 'ubuntu-latest' - browser: 'chromium' - node-version: 18.18.0 - events-mode: 'modern-events' - editor-mode: ${{ matrix.editor-mode }} - prod: ${{ matrix.prod }} - override-react-version: beta + # This has been stalling in GitHub CI for unknown reasons, disable for now + # react-beta: + # strategy: + # matrix: + # # Currently using a single combination for every-patch e2e tests of + # # react beta to reduce cost impact + # editor-mode: ['rich-text'] + # prod: [false] + # uses: ./.github/workflows/call-e2e-test.yml + # with: + # os: 'ubuntu-latest' + # browser: 'chromium' + # node-version: 18.18.0 + # events-mode: 'modern-events' + # editor-mode: ${{ matrix.editor-mode }} + # prod: ${{ matrix.prod }} + # override-react-version: beta flaky: strategy: From 027acd601f1cad2ea88da20999d7f043d60cb850 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 16 Oct 2024 07:26:48 -0700 Subject: [PATCH 10/28] [lexical-code] Bug Fix: Add global type declarations for Prism (#6736) --- packages/lexical-code/src/CodeHighlighterPrism.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/lexical-code/src/CodeHighlighterPrism.ts b/packages/lexical-code/src/CodeHighlighterPrism.ts index 98b17d8f43c..bc2d506e198 100644 --- a/packages/lexical-code/src/CodeHighlighterPrism.ts +++ b/packages/lexical-code/src/CodeHighlighterPrism.ts @@ -25,4 +25,11 @@ import 'prismjs/components/prism-typescript'; import 'prismjs/components/prism-java'; import 'prismjs/components/prism-cpp'; -export const Prism: typeof import('prismjs') = globalThis.Prism || window.Prism; +declare global { + interface Window { + Prism: typeof import('prismjs'); + } +} + +export const Prism: typeof import('prismjs') = + (globalThis as {Prism?: typeof import('prismjs')}).Prism || window.Prism; From 0e4b434eaf37cf79f68a09f3eeec8867644aced5 Mon Sep 17 00:00:00 2001 From: Neysan Foo Date: Wed, 16 Oct 2024 22:41:08 +0800 Subject: [PATCH 11/28] [lexical-playground] Bug Fix: Disable image and inline focusing, adding caption and editing in read-only mode (#6705) --- .../src/nodes/ImageComponent.tsx | 4 ++- .../InlineImageNode/InlineImageComponent.tsx | 34 +++++++++++-------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/packages/lexical-playground/src/nodes/ImageComponent.tsx b/packages/lexical-playground/src/nodes/ImageComponent.tsx index 855c316e6e3..7cd2deff7ce 100644 --- a/packages/lexical-playground/src/nodes/ImageComponent.tsx +++ b/packages/lexical-playground/src/nodes/ImageComponent.tsx @@ -26,6 +26,7 @@ import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin'; import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { @@ -171,6 +172,7 @@ export default function ImageComponent({ const [selection, setSelection] = useState(null); const activeEditorRef = useRef(null); const [isLoadError, setIsLoadError] = useState(false); + const isEditable = useLexicalEditable(); const $onDelete = useCallback( (payload: KeyboardEvent) => { @@ -398,7 +400,7 @@ export default function ImageComponent({ } = useSettings(); const draggable = isSelected && $isNodeSelection(selection) && !isResizing; - const isFocused = isSelected || isResizing; + const isFocused = (isSelected || isResizing) && isEditable; return ( <> diff --git a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageComponent.tsx b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageComponent.tsx index 5cb86ca2426..4a2629ffb45 100644 --- a/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageComponent.tsx +++ b/packages/lexical-playground/src/nodes/InlineImageNode/InlineImageComponent.tsx @@ -15,6 +15,7 @@ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer'; import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { @@ -200,6 +201,7 @@ export default function InlineImageComponent({ const [editor] = useLexicalComposerContext(); const [selection, setSelection] = useState(null); const activeEditorRef = useRef(null); + const isEditable = useLexicalEditable(); const $onDelete = useCallback( (payload: KeyboardEvent) => { @@ -352,25 +354,27 @@ export default function InlineImageComponent({ ]); const draggable = isSelected && $isNodeSelection(selection); - const isFocused = isSelected; + const isFocused = isSelected && isEditable; return ( <> - + {isEditable && ( + + )} Date: Wed, 16 Oct 2024 08:48:23 -0600 Subject: [PATCH 12/28] [lexical-markdown] Feature: add ability to hook into the import process for multiline element transformers (#6682) Co-authored-by: Sherry --- .../lexical-markdown/src/MarkdownImport.ts | 24 +++- .../src/MarkdownTransformers.ts | 13 ++ .../__tests__/unit/LexicalMarkdown.test.ts | 117 ++++++++++++++++++ 3 files changed, 149 insertions(+), 5 deletions(-) diff --git a/packages/lexical-markdown/src/MarkdownImport.ts b/packages/lexical-markdown/src/MarkdownImport.ts index 99b7900b144..2f7dc27324b 100644 --- a/packages/lexical-markdown/src/MarkdownImport.ts +++ b/packages/lexical-markdown/src/MarkdownImport.ts @@ -117,16 +117,30 @@ function $importMultiline( multilineElementTransformers: Array, rootNode: ElementNode, ): [boolean, number] { - for (const { - regExpStart, - regExpEnd, - replace, - } of multilineElementTransformers) { + for (const transformer of multilineElementTransformers) { + const {handleImportAfterStartMatch, regExpEnd, regExpStart, replace} = + transformer; + const startMatch = lines[startLineIndex].match(regExpStart); if (!startMatch) { continue; // Try next transformer } + if (handleImportAfterStartMatch) { + const result = handleImportAfterStartMatch({ + lines, + rootNode, + startLineIndex, + startMatch, + transformer, + }); + if (result === null) { + continue; + } else if (result) { + return result; + } + } + const regexpEndRegex: RegExp | undefined = typeof regExpEnd === 'object' && 'regExp' in regExpEnd ? regExpEnd.regExp diff --git a/packages/lexical-markdown/src/MarkdownTransformers.ts b/packages/lexical-markdown/src/MarkdownTransformers.ts index b724b3ba0ca..2a335156213 100644 --- a/packages/lexical-markdown/src/MarkdownTransformers.ts +++ b/packages/lexical-markdown/src/MarkdownTransformers.ts @@ -75,6 +75,19 @@ export type ElementTransformer = { }; export type MultilineElementTransformer = { + /** + * Use this function to manually handle the import process, once the `regExpStart` has matched successfully. + * Without providing this function, the default behavior is to match until `regExpEnd` is found, or until the end of the document if `regExpEnd.optional` is true. + * + * @returns a tuple or null. The first element of the returned tuple is a boolean indicating if a multiline element was imported. The second element is the index of the last line that was processed. If null is returned, the next multilineElementTransformer will be tried. If undefined is returned, the default behavior will be used. + */ + handleImportAfterStartMatch?: (args: { + lines: Array; + rootNode: ElementNode; + startLineIndex: number; + startMatch: RegExpMatchArray; + transformer: MultilineElementTransformer; + }) => [boolean, number] | null | undefined; dependencies: Array>; /** * `export` is called when the `$convertToMarkdownString` is called to convert the editor state into markdown. diff --git a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts index d4c1a90148a..be7199eefab 100644 --- a/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts +++ b/packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts @@ -23,6 +23,7 @@ import { TRANSFORMERS, } from '../..'; import { + CODE, MultilineElementTransformer, normalizeMarkdown, } from '../../MarkdownTransformers'; @@ -58,6 +59,115 @@ const MDX_HTML_TRANSFORMER: MultilineElementTransformer = { type: 'multiline-element', }; +const CODE_TAG_COUNTER_EXAMPLE: MultilineElementTransformer = { + dependencies: CODE.dependencies, + export: CODE.export, + handleImportAfterStartMatch({lines, rootNode, startLineIndex, startMatch}) { + const regexpEndRegex: RegExp | undefined = /[ \t]*```$/; + + const isEndOptional = false; + + let endLineIndex = startLineIndex; + const linesLength = lines.length; + + let openedSubStartMatches = 0; + + // check every single line for the closing match. It could also be on the same line as the opening match. + while (endLineIndex < linesLength) { + const potentialSubStartMatch = + lines[endLineIndex].match(/^[ \t]*```(\w+)?/); + + const endMatch = regexpEndRegex + ? lines[endLineIndex].match(regexpEndRegex) + : null; + + if (potentialSubStartMatch) { + if (endMatch) { + if ((potentialSubStartMatch.index ?? 0) < (endMatch.index ?? 0)) { + openedSubStartMatches++; + } + } else { + openedSubStartMatches++; + } + } + + if (endMatch) { + openedSubStartMatches--; + } + + if (!endMatch || openedSubStartMatches > 0) { + if ( + !isEndOptional || + (isEndOptional && endLineIndex < linesLength - 1) // Optional end, but didn't reach the end of the document yet => continue searching for potential closing match + ) { + endLineIndex++; + continue; // Search next line for closing match + } + } + + // Now, check if the closing match matched is the same as the opening match. + // If it is, we need to continue searching for the actual closing match. + if ( + endMatch && + startLineIndex === endLineIndex && + endMatch.index === startMatch.index + ) { + endLineIndex++; + continue; // Search next line for closing match + } + + // At this point, we have found the closing match. Next: calculate the lines in between open and closing match + // This should not include the matches themselves, and be split up by lines + const linesInBetween: string[] = []; + + if (endMatch && startLineIndex === endLineIndex) { + linesInBetween.push( + lines[startLineIndex].slice( + startMatch[0].length, + -endMatch[0].length, + ), + ); + } else { + for (let i = startLineIndex; i <= endLineIndex; i++) { + if (i === startLineIndex) { + const text = lines[i].slice(startMatch[0].length); + linesInBetween.push(text); // Also include empty text + } else if (i === endLineIndex && endMatch) { + const text = lines[i].slice(0, -endMatch[0].length); + linesInBetween.push(text); // Also include empty text + } else { + linesInBetween.push(lines[i]); + } + } + } + + if ( + CODE.replace( + rootNode, + null, + startMatch, + endMatch, + linesInBetween, + true, + ) !== false + ) { + // Return here. This $importMultiline function is run line by line and should only process a single multiline element at a time. + return [true, endLineIndex]; + } + + // The replace function returned false, despite finding the matching open and close tags => this transformer does not want to handle it. + // Thus, we continue letting the remaining transformers handle the passed lines of text from the beginning + break; + } + + // No multiline transformer handled this line successfully + return [false, startLineIndex]; + }, + regExpStart: CODE.regExpStart, + replace: CODE.replace, + type: 'multiline-element', +}; + describe('Markdown', () => { type Input = Array<{ html: string; @@ -344,6 +454,13 @@ describe('Markdown', () => { shouldMergeAdjacentLines: true, skipExport: true, }, + { + customTransformers: [CODE_TAG_COUNTER_EXAMPLE], + // Ensure special ``` code block supports nested code blocks + html: '
Code\n```ts\nSub Code\n```
', + md: '```ts\nCode\n```ts\nSub Code\n```\n```', + skipExport: true, + }, ]; const HIGHLIGHT_TEXT_MATCH_IMPORT: TextMatchTransformer = { From ad1d14efc3a06ea692d293135d79ca9ffdfd900b Mon Sep 17 00:00:00 2001 From: Neysan Foo Date: Thu, 17 Oct 2024 04:21:42 +0800 Subject: [PATCH 13/28] [lexical-playground] Bug Fix: Disable equation editing in read-only mode (#6707) --- .../src/nodes/EquationComponent.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/lexical-playground/src/nodes/EquationComponent.tsx b/packages/lexical-playground/src/nodes/EquationComponent.tsx index e4eeb64aeb2..6929f5363f7 100644 --- a/packages/lexical-playground/src/nodes/EquationComponent.tsx +++ b/packages/lexical-playground/src/nodes/EquationComponent.tsx @@ -7,6 +7,7 @@ */ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, @@ -37,6 +38,7 @@ export default function EquationComponent({ nodeKey, }: EquationComponentProps): JSX.Element { const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); const [equationValue, setEquationValue] = useState(equation); const [showEquationEditor, setShowEquationEditor] = useState(false); const inputRef = useRef(null); @@ -64,6 +66,9 @@ export default function EquationComponent({ }, [showEquationEditor, equation, equationValue]); useEffect(() => { + if (!isEditable) { + return; + } if (showEquationEditor) { return mergeRegister( editor.registerCommand( @@ -107,11 +112,11 @@ export default function EquationComponent({ } }); } - }, [editor, nodeKey, onHide, showEquationEditor]); + }, [editor, nodeKey, onHide, showEquationEditor, isEditable]); return ( <> - {showEquationEditor ? ( + {showEquationEditor && isEditable ? ( setShowEquationEditor(true)} + onDoubleClick={() => { + if (isEditable) { + setShowEquationEditor(true); + } + }} /> )} From 24e83416064fbc5d4dc87a31f789b3c9bdda7b36 Mon Sep 17 00:00:00 2001 From: Katsia <47710336+KatsiarynaDzibrova@users.noreply.github.com> Date: Wed, 16 Oct 2024 23:22:20 +0100 Subject: [PATCH 14/28] Bug Fix: Shift+down selects an extra subsequent element for Table selection (#6679) Co-authored-by: Ivaylo Pavlov --- .../__tests__/e2e/Selection.spec.mjs | 59 ++++++++++++++-- .../src/LexicalTableSelectionHelpers.ts | 69 ++++++++++++++++++- 2 files changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs index d458cb1ea6a..026cd94d92d 100644 --- a/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Selection.spec.mjs @@ -1011,8 +1011,8 @@ test.describe.parallel('Selection', () => { await assertSelection(page, { anchorOffset: 0, anchorPath: [0], - focusOffset: 0, - focusPath: [2], + focusOffset: 1, + focusPath: [1, 2, 1], }); }); @@ -1036,8 +1036,8 @@ test.describe.parallel('Selection', () => { await assertSelection(page, { anchorOffset: 0, anchorPath: [2], - focusOffset: 0, - focusPath: [0], + focusOffset: 1, + focusPath: [1, 1, 0], }); }); @@ -1147,4 +1147,55 @@ test.describe.parallel('Selection', () => { focus: {x: 1, y: 1}, }); }); + + test('shift+arrowdown into a table does not select element after', async ({ + page, + isPlainText, + isCollab, + legacyEvents, + browserName, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await insertTable(page, 2, 2); + + await moveToEditorEnd(page); + await page.keyboard.type('def'); + + await moveToEditorBeginning(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.up('Shift'); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [0], + focusOffset: 1, + focusPath: [1, 2, 1], + }); + }); + + test('shift+arrowup into a table does not select element before', async ({ + page, + isPlainText, + isCollab, + legacyEvents, + browserName, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await insertTable(page, 2, 2); + await moveToEditorBeginning(page); + await page.keyboard.type('abc'); + + await moveToEditorEnd(page); + await page.keyboard.down('Shift'); + await page.keyboard.press('ArrowUp'); + await page.keyboard.up('Shift'); + await assertSelection(page, { + anchorOffset: 0, + anchorPath: [2], + focusOffset: 1, + focusPath: [1, 1, 0], + }); + }); }); diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index be84112c306..f85709749b4 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -1401,8 +1401,64 @@ function $handleArrowKey( (direction === 'up' || direction === 'down') ) { const focusNode = selection.focus.getNode(); - if ($isRootOrShadowRoot(focusNode)) { - const selectedNode = selection.getNodes()[0]; + const isTableUnselect = + !selection.isCollapsed() && + ((direction === 'up' && !selection.isBackward()) || + (direction === 'down' && selection.isBackward())); + if (isTableUnselect) { + let focusParentNode = $findMatchingParent(focusNode, (n) => + $isTableNode(n), + ); + if ($isTableCellNode(focusParentNode)) { + focusParentNode = $findMatchingParent( + focusParentNode, + $isTableNode, + ); + } + if (focusParentNode !== tableNode) { + return false; + } + if (!focusParentNode) { + return false; + } + const sibling = + direction === 'down' + ? focusParentNode.getNextSibling() + : focusParentNode.getPreviousSibling(); + if (!sibling) { + return false; + } + let newOffset = 0; + if (direction === 'up') { + if ($isElementNode(sibling)) { + newOffset = sibling.getChildrenSize(); + } + } + let newFocusNode = sibling; + if (direction === 'up') { + if ($isElementNode(sibling)) { + const lastCell = sibling.getLastChild(); + newFocusNode = lastCell ? lastCell : sibling; + newOffset = $isTextNode(newFocusNode) + ? newFocusNode.getTextContentSize() + : 0; + } + } + const newSelection = selection.clone(); + + newSelection.focus.set( + newFocusNode.getKey(), + newOffset, + $isTextNode(newFocusNode) ? 'text' : 'element', + ); + $setSelection(newSelection); + stopEvent(event); + return true; + } else if ($isRootOrShadowRoot(focusNode)) { + const selectedNode = + direction === 'up' + ? selection.getNodes()[selection.getNodes().length - 1] + : selection.getNodes()[0]; if (selectedNode) { const tableCellNode = $findMatchingParent( selectedNode, @@ -1441,10 +1497,16 @@ function $handleArrowKey( } return false; } else { - const focusParentNode = $findMatchingParent( + let focusParentNode = $findMatchingParent( focusNode, (n) => $isElementNode(n) && !n.isInline(), ); + if ($isTableCellNode(focusParentNode)) { + focusParentNode = $findMatchingParent( + focusParentNode, + $isTableNode, + ); + } if (!focusParentNode) { return false; } @@ -1469,6 +1531,7 @@ function $handleArrowKey( direction === 'up' ? 0 : lastCellNode.getChildrenSize(), 'element', ); + stopEvent(event); $setSelection(newSelection); return true; } From 790b5161d2f15e22bc5d7037a2e2f5fca5795af7 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Thu, 17 Oct 2024 04:30:54 +0530 Subject: [PATCH 15/28] [lexical-table] Return inserted node from `$insertTableRow__EXPERIMENTAL` and `$insertTableColumn__EXPERIMENTAL` (#6741) --- .../lexical-table/flow/LexicalTable.js.flow | 4 ++-- .../lexical-table/src/LexicalTableUtils.ts | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/lexical-table/flow/LexicalTable.js.flow b/packages/lexical-table/flow/LexicalTable.js.flow index 0c58fc56374..8ec1b813b10 100644 --- a/packages/lexical-table/flow/LexicalTable.js.flow +++ b/packages/lexical-table/flow/LexicalTable.js.flow @@ -237,11 +237,11 @@ declare export function $deleteTableColumn( declare export function $insertTableRow__EXPERIMENTAL( insertAfter: boolean, -): void; +): TableRowNode | null; declare export function $insertTableColumn__EXPERIMENTAL( insertAfter: boolean, -): void; +): TableCellNode | null; declare export function $deleteTableRow__EXPERIMENTAL(): void; diff --git a/packages/lexical-table/src/LexicalTableUtils.ts b/packages/lexical-table/src/LexicalTableUtils.ts index 7a4aa7534ea..c2cce0126a1 100644 --- a/packages/lexical-table/src/LexicalTableUtils.ts +++ b/packages/lexical-table/src/LexicalTableUtils.ts @@ -245,7 +245,14 @@ const getHeaderState = ( return TableCellHeaderStates.NO_STATUS; }; -export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void { +/** + * Inserts a table row before or after the current focus cell node, + * taking into account any spans. If successful, returns the + * inserted table row node. + */ +export function $insertTableRow__EXPERIMENTAL( + insertAfter = true, +): TableRowNode | null { const selection = $getSelection(); invariant( $isRangeSelection(selection) || $isTableSelection(selection), @@ -256,6 +263,7 @@ export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void { const [gridMap, focusCellMap] = $computeTableMap(grid, focusCell, focusCell); const columnCount = gridMap[0].length; const {startRow: focusStartRow} = focusCellMap; + let insertedRow: TableRowNode | null = null; if (insertAfter) { const focusEndRow = focusStartRow + focusCell.__rowSpan - 1; const focusEndRowMap = gridMap[focusEndRow]; @@ -284,6 +292,7 @@ export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void { 'focusEndRow is not a TableRowNode', ); focusEndRowNode.insertAfter(newRow); + insertedRow = newRow; } else { const focusStartRowMap = gridMap[focusStartRow]; const newRow = $createTableRowNode(); @@ -311,7 +320,9 @@ export function $insertTableRow__EXPERIMENTAL(insertAfter = true): void { 'focusEndRow is not a TableRowNode', ); focusStartRowNode.insertBefore(newRow); + insertedRow = newRow; } + return insertedRow; } export function $insertTableColumn( @@ -373,7 +384,14 @@ export function $insertTableColumn( return tableNode; } -export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void { +/** + * Inserts a column before or after the current focus cell node, + * taking into account any spans. If successful, returns the + * first inserted cell node. + */ +export function $insertTableColumn__EXPERIMENTAL( + insertAfter = true, +): TableCellNode | null { const selection = $getSelection(); invariant( $isRangeSelection(selection) || $isTableSelection(selection), @@ -479,6 +497,7 @@ export function $insertTableColumn__EXPERIMENTAL(insertAfter = true): void { newColWidths.splice(columnIndex, 0, newWidth); grid.setColWidths(newColWidths); } + return firstInsertedCell; } export function $deleteTableColumn( From b2d57a0162671ab339a3b6bb578218d916ff2131 Mon Sep 17 00:00:00 2001 From: EJ Hammond <67350250+ejhammond@users.noreply.github.com> Date: Sun, 20 Oct 2024 14:40:36 -0400 Subject: [PATCH 16/28] [lexical-react] Feature: Add aria-errormessage and aria-invalid support to LexicalContentEditable (#6745) --- .../lexical-react/flow/LexicalContentEditable.js.flow | 3 +++ .../src/shared/LexicalContentEditableElement.tsx | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/lexical-react/flow/LexicalContentEditable.js.flow b/packages/lexical-react/flow/LexicalContentEditable.js.flow index c5a7feb55fc..270ea7998ca 100644 --- a/packages/lexical-react/flow/LexicalContentEditable.js.flow +++ b/packages/lexical-react/flow/LexicalContentEditable.js.flow @@ -24,6 +24,9 @@ type HTMLDivElementDOMProps = $ReadOnly<{ 'aria-labeledby'?: void | string, 'aria-activedescendant'?: void | string, 'aria-autocomplete'?: void | string, + 'aria-describedby'?: void | string, + 'aria-errormessage'?: void | string, + 'aria-invalid'?: void | boolean, 'aria-owns'?: void | string, 'title'?: void | string, onClick?: void | (e: SyntheticEvent) => mixed, diff --git a/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx b/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx index 2e1208e0d64..373bbceba73 100644 --- a/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx +++ b/packages/lexical-react/src/shared/LexicalContentEditableElement.tsx @@ -20,7 +20,9 @@ export type Props = { ariaAutoComplete?: React.AriaAttributes['aria-autocomplete']; ariaControls?: React.AriaAttributes['aria-controls']; ariaDescribedBy?: React.AriaAttributes['aria-describedby']; + ariaErrorMessage?: React.AriaAttributes['aria-errormessage']; ariaExpanded?: React.AriaAttributes['aria-expanded']; + ariaInvalid?: React.AriaAttributes['aria-invalid']; ariaLabel?: React.AriaAttributes['aria-label']; ariaLabelledBy?: React.AriaAttributes['aria-labelledby']; ariaMultiline?: React.AriaAttributes['aria-multiline']; @@ -37,7 +39,9 @@ function ContentEditableElementImpl( ariaAutoComplete, ariaControls, ariaDescribedBy, + ariaErrorMessage, ariaExpanded, + ariaInvalid, ariaLabel, ariaLabelledBy, ariaMultiline, @@ -89,9 +93,15 @@ function ContentEditableElementImpl( aria-autocomplete={isEditable ? ariaAutoComplete : 'none'} aria-controls={isEditable ? ariaControls : undefined} aria-describedby={ariaDescribedBy} + // for compat, only override aria-errormessage if ariaErrorMessage is defined + {...(ariaErrorMessage != null + ? {'aria-errormessage': ariaErrorMessage} + : {})} aria-expanded={ isEditable && role === 'combobox' ? !!ariaExpanded : undefined } + // for compat, only override aria-invalid if ariaInvalid is defined + {...(ariaInvalid != null ? {'aria-invalid': ariaInvalid} : {})} aria-label={ariaLabel} aria-labelledby={ariaLabelledBy} aria-multiline={ariaMultiline} From 1b4f1e0b820b1d3257b9161f69bec716a6bdb179 Mon Sep 17 00:00:00 2001 From: EJ Hammond <67350250+ejhammond@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:27:55 -0400 Subject: [PATCH 17/28] Add ariaErrorMessage and ariaInvalid to Flow type (#6751) --- packages/lexical-react/flow/LexicalContentEditable.js.flow | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lexical-react/flow/LexicalContentEditable.js.flow b/packages/lexical-react/flow/LexicalContentEditable.js.flow index 270ea7998ca..3c50a112513 100644 --- a/packages/lexical-react/flow/LexicalContentEditable.js.flow +++ b/packages/lexical-react/flow/LexicalContentEditable.js.flow @@ -63,7 +63,9 @@ export type Props = $ReadOnly<{ ariaAutoComplete?: string, ariaControls?: string, ariaDescribedBy?: string, + ariaErrorMessage?: string, ariaExpanded?: boolean, + ariaInvalid?: boolean, ariaLabel?: string, ariaLabelledBy?: string, ariaMultiline?: boolean, From 6e299aa37e2baa3040ddc21fdf71cf55f7357f90 Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Mon, 21 Oct 2024 22:34:03 +0100 Subject: [PATCH 18/28] [lexical-table] [lexical-selection] Try to fix calling split on undefined (#6746) --- packages/lexical-selection/src/utils.ts | 3 +++ packages/lexical-table/src/LexicalTableCellNode.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lexical-selection/src/utils.ts b/packages/lexical-selection/src/utils.ts index 0608706eab7..df85bc72c24 100644 --- a/packages/lexical-selection/src/utils.ts +++ b/packages/lexical-selection/src/utils.ts @@ -176,6 +176,9 @@ export function createRectsFromDOMRange( */ export function getStyleObjectFromRawCSS(css: string): Record { const styleObject: Record = {}; + if (!css) { + return styleObject; + } const styles = css.split(';'); for (const style of styles) { diff --git a/packages/lexical-table/src/LexicalTableCellNode.ts b/packages/lexical-table/src/LexicalTableCellNode.ts index 7f752777dc0..525a8bce82c 100644 --- a/packages/lexical-table/src/LexicalTableCellNode.ts +++ b/packages/lexical-table/src/LexicalTableCellNode.ts @@ -322,7 +322,7 @@ export function $convertTableCellNodeElement( } const style = domNode_.style; - const textDecoration = style.textDecoration.split(' '); + const textDecoration = ((style && style.textDecoration) || '').split(' '); const hasBoldFontWeight = style.fontWeight === '700' || style.fontWeight === 'bold'; const hasLinethroughTextDecoration = textDecoration.includes('line-through'); From 3055e54acd7088b2e413a97d7b0c99c4456b852c Mon Sep 17 00:00:00 2001 From: Neysan Foo Date: Tue, 22 Oct 2024 08:36:33 +0800 Subject: [PATCH 19/28] [lexical-playground] Bug Fix: Disable table hover actions in read-only mode (#6706) --- .../plugins/TableHoverActionsPlugin/index.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx index 4c88e8d52b3..44fc3368a02 100644 --- a/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/TableHoverActionsPlugin/index.tsx @@ -7,6 +7,7 @@ */ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import { $getTableColumnIndexFromTableCellNode, $getTableRowIndexFromTableCellNode, @@ -32,8 +33,9 @@ function TableHoverActionsContainer({ anchorElem, }: { anchorElem: HTMLElement; -}): JSX.Element { +}): JSX.Element | null { const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); const [isShownRow, setShownRow] = useState(false); const [isShownColumn, setShownColumn] = useState(false); const [shouldListenMouseMove, setShouldListenMouseMove] = @@ -215,6 +217,10 @@ function TableHoverActionsContainer({ }); }; + if (!isEditable) { + return null; + } + return ( <> {isShownRow && ( @@ -268,8 +274,12 @@ export default function TableHoverActionsPlugin({ }: { anchorElem?: HTMLElement; }): React.ReactPortal | null { - return createPortal( - , - anchorElem, - ); + const isEditable = useLexicalEditable(); + + return isEditable + ? createPortal( + , + anchorElem, + ) + : null; } From 4e1a3f43955bd103686731a89fa3dd3a1494684b Mon Sep 17 00:00:00 2001 From: Neysan Foo Date: Tue, 22 Oct 2024 08:51:19 +0800 Subject: [PATCH 20/28] [lexical-playground] Bug Fix: Disable editing of Excalidraw Component in Read-Only Mode (#6704) Co-authored-by: Bob Ippolito --- .../ExcalidrawNode/ExcalidrawComponent.tsx | 61 ++++++++++--------- 1 file changed, 33 insertions(+), 28 deletions(-) diff --git a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx index 646d0004a38..5a45068469e 100644 --- a/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx +++ b/packages/lexical-playground/src/nodes/ExcalidrawNode/ExcalidrawComponent.tsx @@ -11,6 +11,7 @@ import type {NodeKey} from 'lexical'; import {AppState, BinaryFiles} from '@excalidraw/excalidraw/types/types'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useLexicalEditable} from '@lexical/react/useLexicalEditable'; import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; import {mergeRegister} from '@lexical/utils'; import { @@ -40,6 +41,7 @@ export default function ExcalidrawComponent({ height: 'inherit' | number; }): JSX.Element { const [editor] = useLexicalComposerContext(); + const isEditable = useLexicalEditable(); const [isModalOpen, setModalOpen] = useState( data === '[]' && editor.isEditable(), ); @@ -66,16 +68,13 @@ export default function ExcalidrawComponent({ [editor, isSelected, nodeKey], ); - // Set editor to readOnly if Excalidraw is open to prevent unwanted changes useEffect(() => { - if (isModalOpen) { - editor.setEditable(false); - } else { - editor.setEditable(true); + if (!isEditable) { + if (isSelected) { + clearSelection(); + } + return; } - }, [isModalOpen, editor]); - - useEffect(() => { return mergeRegister( editor.registerCommand( CLICK_COMMAND, @@ -113,7 +112,15 @@ export default function ExcalidrawComponent({ COMMAND_PRIORITY_LOW, ), ); - }, [clearSelection, editor, isSelected, isResizing, $onDelete, setSelected]); + }, [ + clearSelection, + editor, + isSelected, + isResizing, + $onDelete, + setSelected, + isEditable, + ]); const deleteNode = useCallback(() => { setModalOpen(false); @@ -130,9 +137,6 @@ export default function ExcalidrawComponent({ aps: Partial, fls: BinaryFiles, ) => { - if (!editor.isEditable()) { - return; - } return editor.update(() => { const node = $getNodeByKey(nodeKey); if ($isExcalidrawNode(node)) { @@ -198,20 +202,21 @@ export default function ExcalidrawComponent({ return ( <> - { - editor.setEditable(true); - setData(els, aps, fls); - setModalOpen(false); - }} - closeOnClickOutside={false} - /> + {isEditable && isModalOpen && ( + { + setData(els, aps, fls); + setModalOpen(false); + }} + closeOnClickOutside={false} + /> + )} {elements.length > 0 && (