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 `<a>` 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.
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2022-11-27 13:33:12 +03:00
1 parent 1ec17c41ec
commit b80b4c21f7
3 files changed
+81 -16

No files matched your search

@@ -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());
@@ -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 <a>
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 (
<LexicalComposer initialConfig={initialConfig}>
<AutofocusPlugin />
<RemoveParagaphsPlugin />
<ToolbarConnectorPlugin />
<BindInstanceToNodePlugin refs={refs} />
<RichTextPlugin
@@ -54,10 +54,6 @@ const helperStyles = [
`[${idAttribute}][contenteditable] {
box-shadow: 0 0 0 4px rgb(36 150 255 / 20%)
}`,
// Text Editor wraps each line into a p, so we need to make sure there is no jump between regular rendering and editing
`[${idAttribute}][contenteditable] p {
margin: 0
}`,
];
export const useManageDesignModeStyles = () => {