From 7080a7992ecd035d0de7b3cb79d3d5e2e3d26e8b Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Tue, 7 Jan 2025 21:49:46 +0000 Subject: [PATCH 1/7] Bullet color to follow text color --- packages/lexical-list/src/LexicalListItemNode.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index 0e5153bc256..a709cc7ba95 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -95,7 +95,9 @@ export class ListItemNode extends ElementNode { // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; $setListItemThemeClassNames(dom, config.theme, this); - + if (dom.childNodes.length > 0) { + dom.style.color = dom.lastChild.style.color; + } return false; } From af615a0e0c15642342b2c76fd40d97fc3341a134 Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Sun, 12 Jan 2025 22:44:29 +0000 Subject: [PATCH 2/7] Using Listeners --- .../lexical-list/src/LexicalListItemNode.ts | 3 - packages/lexical-list/src/index.ts | 76 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index a709cc7ba95..db0f62bb020 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -95,9 +95,6 @@ export class ListItemNode extends ElementNode { // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; $setListItemThemeClassNames(dom, config.theme, this); - if (dom.childNodes.length > 0) { - dom.style.color = dom.lastChild.style.color; - } return false; } diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index d04909343c5..229ebbe63be 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -8,13 +8,17 @@ import type {SerializedListItemNode} from './LexicalListItemNode'; import type {ListType, SerializedListNode} from './LexicalListNode'; -import type {LexicalCommand, LexicalEditor} from 'lexical'; +import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; -import {mergeRegister} from '@lexical/utils'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; import { + $getNodeByKey, + $getSelection, + $isRangeSelection, COMMAND_PRIORITY_LOW, createCommand, INSERT_PARAGRAPH_COMMAND, + TextNode, } from 'lexical'; import { @@ -97,6 +101,74 @@ export function registerList(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_LOW, ), + editor.registerMutationListener( + ListItemNode, + (mutations) => { + editor.update(() => { + for (const [key, type] of mutations) { + if (type === 'created') { + const listItemElement = editor.getElementByKey(key); + if (listItemElement !== null) { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + listItemElement.setAttribute('style', selection.style || ''); + } + } + } + if (type === 'updated') { + const node = $getNodeByKey(key); + const listItemElement = editor.getElementByKey(key); + if (node !== null) { + const firstChild = node.getFirstChild(); + if (firstChild) { + const textElement = editor.getElementByKey( + firstChild.getKey(), + ); + if (listItemElement && textElement) { + listItemElement.setAttribute( + 'style', + textElement.style.cssText, + ); + } + } + } + } + } + }); + }, + {skipInitialization: false}, + ), + editor.registerMutationListener( + TextNode, + (mutations) => { + editor.update(() => { + for (const [key, type] of mutations) { + if (type === 'updated') { + const node = $getNodeByKey(key); + if (node) { + const listItemParentNode = $findMatchingParent( + node, + $isListItemNode, + ); + if (listItemParentNode) { + const textElement = editor.getElementByKey(key); + const listItemElement = editor.getElementByKey( + listItemParentNode.getKey(), + ); + if (listItemElement && textElement) { + listItemElement.setAttribute( + 'style', + textElement.style.cssText, + ); + } + } + } + } + } + }); + }, + {skipInitialization: false}, + ), ); return removeListener; } From 6b7f32c3d45148e8e2877afb2e50f20c667d2a43 Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Tue, 14 Jan 2025 23:29:15 +0000 Subject: [PATCH 3/7] Implement suggestions --- packages/lexical-list/src/index.ts | 54 ++++++++---------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index 229ebbe63be..7d5fa79aac3 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -10,7 +10,7 @@ import type {SerializedListItemNode} from './LexicalListItemNode'; import type {ListType, SerializedListNode} from './LexicalListNode'; import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; -import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import {mergeRegister} from '@lexical/utils'; import { $getNodeByKey, $getSelection, @@ -106,59 +106,27 @@ export function registerList(editor: LexicalEditor): () => void { (mutations) => { editor.update(() => { for (const [key, type] of mutations) { - if (type === 'created') { - const listItemElement = editor.getElementByKey(key); - if (listItemElement !== null) { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - listItemElement.setAttribute('style', selection.style || ''); - } - } - } - if (type === 'updated') { + if (type !== 'destroyed') { const node = $getNodeByKey(key); const listItemElement = editor.getElementByKey(key); - if (node !== null) { + if (node && listItemElement) { const firstChild = node.getFirstChild(); if (firstChild) { const textElement = editor.getElementByKey( firstChild.getKey(), ); - if (listItemElement && textElement) { + if (textElement) { listItemElement.setAttribute( 'style', textElement.style.cssText, ); } - } - } - } - } - }); - }, - {skipInitialization: false}, - ), - editor.registerMutationListener( - TextNode, - (mutations) => { - editor.update(() => { - for (const [key, type] of mutations) { - if (type === 'updated') { - const node = $getNodeByKey(key); - if (node) { - const listItemParentNode = $findMatchingParent( - node, - $isListItemNode, - ); - if (listItemParentNode) { - const textElement = editor.getElementByKey(key); - const listItemElement = editor.getElementByKey( - listItemParentNode.getKey(), - ); - if (listItemElement && textElement) { + } else { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { listItemElement.setAttribute( 'style', - textElement.style.cssText, + selection.style || '', ); } } @@ -169,6 +137,12 @@ export function registerList(editor: LexicalEditor): () => void { }, {skipInitialization: false}, ), + editor.registerNodeTransform(TextNode, (node) => { + const listItemParentNode = node.getParent(); + if ($isListItemNode(listItemParentNode)) { + listItemParentNode.markDirty(); + } + }), ); return removeListener; } From 5869d8390aac5367078197653bf2cbdc453696fc Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Sun, 19 Jan 2025 00:20:57 +0000 Subject: [PATCH 4/7] Check selection is collapsed --- packages/lexical-list/src/index.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index 7d5fa79aac3..1e9b1d2507d 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -115,7 +115,7 @@ export function registerList(editor: LexicalEditor): () => void { const textElement = editor.getElementByKey( firstChild.getKey(), ); - if (textElement) { + if (textElement && textElement.style.cssText) { listItemElement.setAttribute( 'style', textElement.style.cssText, @@ -123,11 +123,12 @@ export function registerList(editor: LexicalEditor): () => void { } } else { const selection = $getSelection(); - if ($isRangeSelection(selection)) { - listItemElement.setAttribute( - 'style', - selection.style || '', - ); + if ( + $isRangeSelection(selection) && + selection.isCollapsed() && + selection.style + ) { + listItemElement.setAttribute('style', selection.style); } } } From 562267224b9f9732b730bc426f30c9e4708fb1ea Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Sun, 19 Jan 2025 01:00:56 +0000 Subject: [PATCH 5/7] Fix test --- packages/lexical-playground/__tests__/e2e/List.spec.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lexical-playground/__tests__/e2e/List.spec.mjs b/packages/lexical-playground/__tests__/e2e/List.spec.mjs index d4c1bc250e8..7079fbe7206 100644 --- a/packages/lexical-playground/__tests__/e2e/List.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/List.spec.mjs @@ -176,7 +176,7 @@ test.describe.parallel('Nested List', () => { await assertHTML( page, - '
  • Hello

World

', + '
  • Hello

World

', ); }); From f9f3cc2b828928ce53b188fe954927fe64b253ff Mon Sep 17 00:00:00 2001 From: Ivaylo Pavlov Date: Tue, 28 Jan 2025 00:05:31 +0000 Subject: [PATCH 6/7] nodetransform --- .../lexical-list/src/LexicalListItemNode.ts | 1 + packages/lexical-list/src/index.ts | 64 ++++++++----------- 2 files changed, 28 insertions(+), 37 deletions(-) diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index db0f62bb020..0e5153bc256 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -95,6 +95,7 @@ export class ListItemNode extends ElementNode { // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; $setListItemThemeClassNames(dom, config.theme, this); + return false; } diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index 1e9b1d2507d..35b7fee05fa 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -12,7 +12,6 @@ import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; import {mergeRegister} from '@lexical/utils'; import { - $getNodeByKey, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW, @@ -101,43 +100,34 @@ export function registerList(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_LOW, ), - editor.registerMutationListener( - ListItemNode, - (mutations) => { - editor.update(() => { - for (const [key, type] of mutations) { - if (type !== 'destroyed') { - const node = $getNodeByKey(key); - const listItemElement = editor.getElementByKey(key); - if (node && listItemElement) { - const firstChild = node.getFirstChild(); - if (firstChild) { - const textElement = editor.getElementByKey( - firstChild.getKey(), - ); - if (textElement && textElement.style.cssText) { - listItemElement.setAttribute( - 'style', - textElement.style.cssText, - ); - } - } else { - const selection = $getSelection(); - if ( - $isRangeSelection(selection) && - selection.isCollapsed() && - selection.style - ) { - listItemElement.setAttribute('style', selection.style); - } - } - } - } + editor.registerNodeTransform(ListItemNode, (node) => { + const listItemElement = editor.getElementByKey(node.__key); + if (node && listItemElement) { + const firstChild = node.getFirstChild(); + if (firstChild) { + const textElement = editor.getElementByKey(firstChild.getKey()); + if ( + textElement && + textElement.style.cssText && + textElement.style.cssText !== node.getStyle() + ) { + listItemElement.setAttribute('style', textElement.style.cssText); + node.markDirty(); } - }); - }, - {skipInitialization: false}, - ), + } else { + const selection = $getSelection(); + if ( + $isRangeSelection(selection) && + selection.isCollapsed() && + selection.style && + selection.style !== node.getStyle() + ) { + listItemElement.setAttribute('style', selection.style); + node.markDirty(); + } + } + } + }), editor.registerNodeTransform(TextNode, (node) => { const listItemParentNode = node.getParent(); if ($isListItemNode(listItemParentNode)) { From e8c3774eb139d1b14d3ee38ed92a64a0824a15be Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 28 Jan 2025 13:10:28 -0800 Subject: [PATCH 7/7] Change ListItemNode style based on transforms and selection --- .../lexical-list/src/LexicalListItemNode.ts | 7 ++- packages/lexical-list/src/index.ts | 59 +++++++++++-------- .../lexical-selection/src/lexical-node.ts | 11 +++- packages/lexical/src/LexicalEvents.ts | 36 +++++++---- 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/packages/lexical-list/src/LexicalListItemNode.ts b/packages/lexical-list/src/LexicalListItemNode.ts index 0e5153bc256..3947ba12a63 100644 --- a/packages/lexical-list/src/LexicalListItemNode.ts +++ b/packages/lexical-list/src/LexicalListItemNode.ts @@ -80,6 +80,9 @@ export class ListItemNode extends ElementNode { } element.value = this.__value; $setListItemThemeClassNames(element, config.theme, this); + if (this.__style !== '') { + element.style.cssText = this.__style; + } return element; } @@ -95,7 +98,9 @@ export class ListItemNode extends ElementNode { // @ts-expect-error - this is always HTMLListItemElement dom.value = this.__value; $setListItemThemeClassNames(dom, config.theme, this); - + if (prevNode.__style !== this.__style) { + dom.style.cssText = this.__style; + } return false; } diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts index 35b7fee05fa..6da9d883574 100644 --- a/packages/lexical-list/src/index.ts +++ b/packages/lexical-list/src/index.ts @@ -8,15 +8,18 @@ import type {SerializedListItemNode} from './LexicalListItemNode'; import type {ListType, SerializedListNode} from './LexicalListNode'; -import type {LexicalCommand, LexicalEditor, LexicalNode} from 'lexical'; +import type {LexicalCommand, LexicalEditor} from 'lexical'; import {mergeRegister} from '@lexical/utils'; import { $getSelection, $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, createCommand, INSERT_PARAGRAPH_COMMAND, + SELECTION_CHANGE_COMMAND, TextNode, } from 'lexical'; @@ -61,6 +64,21 @@ export const REMOVE_LIST_COMMAND: LexicalCommand = createCommand( 'REMOVE_LIST_COMMAND', ); +function $checkSelectionListener(): boolean { + const selection = $getSelection(); + if ($isRangeSelection(selection) && selection.isCollapsed()) { + const node = selection.anchor.getNode(); + if ( + $isListItemNode(node) && + node.isEmpty() && + selection.style !== node.getStyle() + ) { + node.setStyle(selection.style); + } + } + return false; +} + export function registerList(editor: LexicalEditor): () => void { const removeListener = mergeRegister( editor.registerCommand( @@ -100,37 +118,26 @@ export function registerList(editor: LexicalEditor): () => void { }, COMMAND_PRIORITY_LOW, ), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + $checkSelectionListener, + COMMAND_PRIORITY_EDITOR, + ), editor.registerNodeTransform(ListItemNode, (node) => { - const listItemElement = editor.getElementByKey(node.__key); - if (node && listItemElement) { - const firstChild = node.getFirstChild(); - if (firstChild) { - const textElement = editor.getElementByKey(firstChild.getKey()); - if ( - textElement && - textElement.style.cssText && - textElement.style.cssText !== node.getStyle() - ) { - listItemElement.setAttribute('style', textElement.style.cssText); - node.markDirty(); - } - } else { - const selection = $getSelection(); - if ( - $isRangeSelection(selection) && - selection.isCollapsed() && - selection.style && - selection.style !== node.getStyle() - ) { - listItemElement.setAttribute('style', selection.style); - node.markDirty(); - } + const firstChild = node.getFirstChild(); + if (firstChild && $isTextNode(firstChild)) { + const style = firstChild.getStyle(); + if (node.getStyle() !== style) { + node.setStyle(style); } } }), editor.registerNodeTransform(TextNode, (node) => { const listItemParentNode = node.getParent(); - if ($isListItemNode(listItemParentNode)) { + if ( + $isListItemNode(listItemParentNode) && + node.is(listItemParentNode.getFirstChild()) + ) { listItemParentNode.markDirty(); } }), diff --git a/packages/lexical-selection/src/lexical-node.ts b/packages/lexical-selection/src/lexical-node.ts index 22074d1fd6e..75061fdf03a 100644 --- a/packages/lexical-selection/src/lexical-node.ts +++ b/packages/lexical-selection/src/lexical-node.ts @@ -17,6 +17,7 @@ import { $isTextNode, $isTokenOrSegmented, BaseSelection, + ElementNode, LexicalEditor, LexicalNode, Point, @@ -241,7 +242,7 @@ export function $addNodeStyle(node: TextNode): void { } function $patchStyle( - target: TextNode | RangeSelection, + target: TextNode | RangeSelection | ElementNode, patch: Record< string, | string @@ -263,7 +264,7 @@ function $patchStyle( } return styles; }, - {...prevStyles} || {}, + {...prevStyles}, ); const newCSSText = getCSSFromStyleObject(newStyles); target.setStyle(newCSSText); @@ -285,12 +286,16 @@ export function $patchStyleText( | null | (( currentStyleValue: string | null, - target: TextNode | RangeSelection, + target: TextNode | RangeSelection | ElementNode, ) => string) >, ): void { if (selection.isCollapsed() && $isRangeSelection(selection)) { $patchStyle(selection, patch); + const emptyNode = selection.anchor.getNode(); + if ($isElementNode(emptyNode) && emptyNode.isEmpty()) { + $patchStyle(emptyNode, patch); + } } else { $forEachSelectedTextNode((textNode) => { $patchStyle(textNode, patch); diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 0ccd47e2910..cbeffe4766e 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -337,31 +337,27 @@ function onSelectionChange( anchor.offset === lastOffset && anchor.key === lastKey ) { - selection.format = lastFormat; - selection.style = lastStyle; + $updateSelectionFormatStyle(selection, lastFormat, lastStyle); } else { if (anchor.type === 'text') { invariant( $isTextNode(anchorNode), 'Point.getNode() must return TextNode when type is text', ); - selection.format = anchorNode.getFormat(); - selection.style = anchorNode.getStyle(); + $updateSelectionFormatStyleFromNode(selection, anchorNode); } else if (anchor.type === 'element' && !isRootTextContentEmpty) { invariant( $isElementNode(anchorNode), 'Point.getNode() must return ElementNode when type is element', ); const lastNode = anchor.getNode(); - selection.style = ''; if ( // This previously applied to all ParagraphNode lastNode.isEmpty() ) { - selection.format = lastNode.getTextFormat(); - selection.style = lastNode.getTextStyle(); + $updateSelectionFormatStyleFromNode(selection, lastNode); } else { - selection.format = 0; + $updateSelectionFormatStyle(selection, 0, ''); } } } @@ -411,6 +407,27 @@ function onSelectionChange( }); } +function $updateSelectionFormatStyle( + selection: RangeSelection, + format: number, + style: string, +) { + if (selection.format !== format || selection.style !== style) { + selection.format = format; + selection.style = style; + selection.dirty = true; + } +} + +function $updateSelectionFormatStyleFromNode( + selection: RangeSelection, + node: TextNode | ElementNode, +) { + const format = node.getFormat(); + const style = node.getStyle(); + $updateSelectionFormatStyle(selection, format, style); +} + // This is a work-around is mainly Chrome specific bug where if you select // the contents of an empty block, you cannot easily unselect anything. // This results in a tiny selection box that looks buggy/broken. This can @@ -578,12 +595,11 @@ function onBeforeInput(event: InputEvent, editor: LexicalEditor): void { if ($isRangeSelection(selection)) { const anchorNode = selection.anchor.getNode(); anchorNode.markDirty(); - selection.format = anchorNode.getFormat(); invariant( $isTextNode(anchorNode), 'Anchor node must be a TextNode', ); - selection.style = anchorNode.getStyle(); + $updateSelectionFormatStyleFromNode(selection, anchorNode); } } else { $setCompositionKey(null);