From b80b4c21f7fff55a7db610ae3bbffbd170ec956d Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 27 Nov 2022 13:33:12 +0300 Subject: [PATCH] Remove paragraphs from text editor (#514) Ref https://github.com/webstudio-is/webstudio-designer/issues/487 Before lexical created paragraphs when press enter which we tried to visually represent as line break by removing margins and converted to line break to instances. This could lead to a few issues. 1. When paragraph block is inside `` nothing can be typed in the beginning and in the end of paragraph 2. Paragraphs can be styled by users which can break text editor visually Here I overwritten default Enter command and insert line break instead of paragraph. To protect from external sources like paste from clipboard added transform to merge all paragraphs if move than one. We will still have one paragraph in the root with display: inline style. Though it will cause less damage to editor with user styles. --- .../canvas/features/text-editor/interop.ts | 8 +- .../features/text-editor/text-editor.tsx | 85 +++++++++++++++++-- apps/designer/app/canvas/shared/styles.ts | 4 - 3 files changed, 81 insertions(+), 16 deletions(-) diff --git a/apps/designer/app/canvas/features/text-editor/interop.ts b/apps/designer/app/canvas/features/text-editor/interop.ts index db7fdb779d..83410d9b12 100644 --- a/apps/designer/app/canvas/features/text-editor/interop.ts +++ b/apps/designer/app/canvas/features/text-editor/interop.ts @@ -32,16 +32,10 @@ const $writeUpdates = ( const children = node.getChildren(); for (const child of children) { if ($isParagraphNode(child)) { - if (updates.length !== 0) { - updates.push("\n"); - } $writeUpdates(child, updates, refs); } if ($isLineBreakNode(child)) { - if (updates.length !== 0) { - // @todo should we visually distinct line breaks and paragraphs? - updates.push("\n"); - } + updates.push("\n"); } if ($isLinkNode(child)) { const id = refs.get(child.getKey()); diff --git a/apps/designer/app/canvas/features/text-editor/text-editor.tsx b/apps/designer/app/canvas/features/text-editor/text-editor.tsx index a9e23aa0f9..4828188d52 100644 --- a/apps/designer/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/designer/app/canvas/features/text-editor/text-editor.tsx @@ -1,4 +1,14 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useLayoutEffect } from "react"; +import { + KEY_ENTER_COMMAND, + INSERT_LINE_BREAK_COMMAND, + COMMAND_PRIORITY_EDITOR, + RootNode, + ElementNode, + $createLineBreakNode, + $getSelection, + $isRangeSelection, +} from "lexical"; import { LinkNode } from "@lexical/link"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; @@ -39,6 +49,65 @@ const AutofocusPlugin = () => { return null; }; +const RemoveParagaphsPlugin = () => { + const [editor] = useLexicalComposerContext(); + + // register own commands before RichTextPlugin + // to stop propagation + useLayoutEffect(() => { + const removeCommand = editor.registerCommand( + KEY_ENTER_COMMAND, + (event) => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return false; + } + event?.preventDefault(); + // returns true which stops propagation + return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); + }, + COMMAND_PRIORITY_EDITOR + ); + + // merge pasted paragraphs into single one + // and separate lines with line breaks + const removeNodeTransform = editor.registerNodeTransform( + RootNode, + (node) => { + // merge paragraphs into first with line breaks between + if (node.getChildrenSize() > 1) { + const children = node.getChildren(); + let first; + for (let index = 0; index < children.length; index += 1) { + const paragraph = children[index]; + // With default configuration root contains only paragraphs. + // Lexical converts headings to paragraphs on paste for example. + // So he we just check root children which are all paragraphs. + if (paragraph instanceof ElementNode) { + if (index === 0) { + first = paragraph; + } else if (first) { + first.append($createLineBreakNode()); + for (const child of paragraph.getChildren()) { + first.append(child); + } + paragraph.remove(); + } + } + } + } + } + ); + + return () => { + removeCommand(); + removeNodeTransform(); + }; + }, [editor]); + + return null; +}; + const onError = (error: Error) => { throw error; }; @@ -54,12 +123,16 @@ export const TextEditor = ({ contentEditable, onChange, }: TextEditorProps) => { + const [paragraphClassName] = useState(() => nanoid()); const [italicClassName] = useState(() => nanoid()); - // initially instance styles are applied to all nodes - // so not necessary to use layout effect - useEffect(() => { + useLayoutEffect(() => { const engine = createCssEngine(); + // reset paragraph styles and make it work inside + engine.addPlaintextRule(` + .${paragraphClassName} { display: inline; margin: 0; } + `); + /// set italic style for bold italic combination on the same element engine.addPlaintextRule(` .${italicClassName} { font-style: italic; } `); @@ -67,7 +140,7 @@ export const TextEditor = ({ return () => { engine.unmount(); }; - }, [italicClassName]); + }, [paragraphClassName, italicClassName]); // store references separately because lexical nodes // cannot store custom data @@ -76,6 +149,7 @@ export const TextEditor = ({ const initialConfig = { namespace: "WsTextEditor", theme: { + paragraph: paragraphClassName, text: { italic: italicClassName, }, @@ -93,6 +167,7 @@ export const TextEditor = ({ return ( + {