mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: Preserve nested links when editing MDX paragraphs
This commit is contained in:
1 parent
50093ed2b7
commit
42a16db02c
4 files changed
+230
-85
No files matched your search
@@ -340,3 +340,64 @@ test.each([
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test.each(["em", "strong", "span"])(
|
||||
"preserves text and a nested link inside %s across edits",
|
||||
async (tag) => {
|
||||
const { instances } = renderData(
|
||||
<ws.element ws:tag="p" ws:id="paragraph">
|
||||
<ws.element ws:tag={tag} ws:id="wrapper">
|
||||
{"Before "}
|
||||
<ws.element ws:tag="a" ws:id="link">
|
||||
linked text
|
||||
</ws.element>
|
||||
{" after."}
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
);
|
||||
const refs: Refs = new Map();
|
||||
const editor = createHeadlessEditor({ nodes: [LinkNode] });
|
||||
await new Promise<void>((resolve) => {
|
||||
editor.update(() => $convertToLexical(instances, "paragraph", refs), {
|
||||
onUpdate: resolve,
|
||||
});
|
||||
});
|
||||
expect(
|
||||
editor.getEditorState().read(() => $getRoot().getTextContent())
|
||||
).toBe("Before linked text after.");
|
||||
await new Promise<void>((resolve) => {
|
||||
editor.update(
|
||||
() => {
|
||||
$getRoot()
|
||||
.getAllTextNodes()
|
||||
.find((node) => node.getTextContent() === "linked text")!
|
||||
.setTextContent("edited link");
|
||||
},
|
||||
{ onUpdate: resolve }
|
||||
);
|
||||
});
|
||||
let id = 0;
|
||||
const updates = editor
|
||||
.getEditorState()
|
||||
.read(() =>
|
||||
$convertToUpdates(
|
||||
instances.get("paragraph")!,
|
||||
refs,
|
||||
new Map(),
|
||||
() => `new-${++id}`
|
||||
)
|
||||
);
|
||||
const expected = renderTemplate(
|
||||
<ws.element ws:tag="p" ws:id="paragraph">
|
||||
<ws.element ws:tag={tag} ws:id="wrapper">
|
||||
{"Before "}
|
||||
<ws.element ws:tag="a" ws:id="link">
|
||||
edited link
|
||||
</ws.element>
|
||||
{" after."}
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
).instances;
|
||||
expect(updates).toEqual(expected);
|
||||
}
|
||||
);
|
||||
@@ -1,12 +1,12 @@
|
||||
import {
|
||||
type TextNode,
|
||||
type ElementNode,
|
||||
type TextFormatType,
|
||||
$getRoot,
|
||||
$createTextNode,
|
||||
$createParagraphNode,
|
||||
$createLineBreakNode,
|
||||
$isTextNode,
|
||||
$isElementNode,
|
||||
$isParagraphNode,
|
||||
$isLineBreakNode,
|
||||
} from "lexical";
|
||||
@@ -45,8 +45,60 @@ const $writeUpdates = (
|
||||
refs: Refs,
|
||||
newLinkKeyToInstanceId: Refs,
|
||||
createId: CreateId,
|
||||
transientTextNodeKeys: ReadonlySet<string>
|
||||
transientTextNodeKeys: ReadonlySet<string>,
|
||||
inheritedFormats: ReadonlySet<string> = new Set()
|
||||
) => {
|
||||
const wrapFormats = (child: TextNode | ElementNode) => {
|
||||
let parentUpdates = instanceChildren;
|
||||
const formats = new Set(inheritedFormats);
|
||||
const textNodes = $isTextNode(child) ? [child] : child.getAllTextNodes();
|
||||
const candidates = [["span", "span"], ...elementLexicalFormats] as const;
|
||||
for (const [format, defaultTag, ...aliases] of candidates) {
|
||||
if (formats.has(format)) {
|
||||
continue;
|
||||
}
|
||||
const alias = aliases.find((tag) => refs.has(`${child.getKey()}:${tag}`));
|
||||
const key = `${child.getKey()}:${alias ?? format}`;
|
||||
if (!$isTextNode(child) && !refs.has(key)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
textNodes.length === 0 ||
|
||||
!textNodes.every((text) =>
|
||||
format === "span" ? $isSpanNode(text) : text.hasFormat(format)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let id = refs.get(key) ?? createId();
|
||||
const previous = parentUpdates.at(-1);
|
||||
const existing = instancesList.find((instance) => instance.id === id);
|
||||
if (
|
||||
previous?.type === "id" &&
|
||||
previous.value === id &&
|
||||
existing !== undefined
|
||||
) {
|
||||
parentUpdates = existing.children;
|
||||
} else {
|
||||
if (existing !== undefined) {
|
||||
id = createId();
|
||||
}
|
||||
const instance: Instance = {
|
||||
type: "instance",
|
||||
id,
|
||||
component: elementComponent,
|
||||
tag: alias ?? defaultTag,
|
||||
children: [],
|
||||
};
|
||||
refs.set(key, id);
|
||||
instancesList.push(instance);
|
||||
parentUpdates.push({ type: "id", value: id });
|
||||
parentUpdates = instance.children;
|
||||
}
|
||||
formats.add(format);
|
||||
}
|
||||
return { parentUpdates, formats };
|
||||
};
|
||||
const children = node.getChildren();
|
||||
for (const child of children) {
|
||||
if ($isParagraphNode(child)) {
|
||||
@@ -64,10 +116,11 @@ const $writeUpdates = (
|
||||
instanceChildren.push({ type: "text", value: "\n" });
|
||||
}
|
||||
if ($isLinkNode(child)) {
|
||||
const { parentUpdates, formats } = wrapFormats(child);
|
||||
const key = child.getKey();
|
||||
const id = refs.get(key) ?? newLinkKeyToInstanceId.get(key) ?? createId();
|
||||
refs.set(key, id);
|
||||
instanceChildren.push({
|
||||
parentUpdates.push({
|
||||
type: "id",
|
||||
value: id,
|
||||
});
|
||||
@@ -79,7 +132,8 @@ const $writeUpdates = (
|
||||
refs,
|
||||
newLinkKeyToInstanceId,
|
||||
createId,
|
||||
transientTextNodeKeys
|
||||
transientTextNodeKeys,
|
||||
formats
|
||||
);
|
||||
instancesList.push({
|
||||
type: "instance",
|
||||
@@ -97,46 +151,7 @@ const $writeUpdates = (
|
||||
// considering lexical represents both as single node
|
||||
// and add ref suffix to distinct styling on one node key
|
||||
const text = child.getTextContent();
|
||||
let parentUpdates = instanceChildren;
|
||||
if ($isSpanNode(child)) {
|
||||
// prematurely generate span id to select it right after applying
|
||||
const key = `${child.getKey()}:span`;
|
||||
const id = refs.get(key) ?? createId();
|
||||
refs.set(key, id);
|
||||
const childChildren: Instance["children"] = [];
|
||||
instancesList.push({
|
||||
type: "instance",
|
||||
id,
|
||||
component: elementComponent,
|
||||
tag: "span",
|
||||
children: childChildren,
|
||||
});
|
||||
parentUpdates.push({ type: "id", value: id });
|
||||
parentUpdates = childChildren;
|
||||
}
|
||||
// convert all lexical formats
|
||||
for (const [format, defaultTag, ...aliases] of elementLexicalFormats) {
|
||||
if (child.hasFormat(format)) {
|
||||
// Preserve semantic MDX tags and their template identity on edits.
|
||||
const alias = aliases.find((tag) =>
|
||||
refs.has(`${child.getKey()}:${tag}`)
|
||||
);
|
||||
const tag = alias ?? defaultTag;
|
||||
const key = `${child.getKey()}:${alias ?? format}`;
|
||||
const id = refs.get(key) ?? createId();
|
||||
refs.set(key, id);
|
||||
const childInstance: Instance = {
|
||||
type: "instance",
|
||||
id,
|
||||
component: elementComponent,
|
||||
tag,
|
||||
children: [],
|
||||
};
|
||||
instancesList.push(childInstance);
|
||||
parentUpdates.push({ type: "id", value: id });
|
||||
parentUpdates = childInstance.children;
|
||||
}
|
||||
}
|
||||
const { parentUpdates } = wrapFormats(child);
|
||||
parentUpdates.push({ type: "text", value: text });
|
||||
}
|
||||
}
|
||||
@@ -176,26 +191,37 @@ export const $convertToPlainTextUpdate = (treeRootInstance: Instance) => [
|
||||
},
|
||||
];
|
||||
|
||||
type InlineFormat = {
|
||||
format: TextFormatType | "span";
|
||||
suffix: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const $writeLexical = (
|
||||
parent: ElementNode | TextNode,
|
||||
parent: ElementNode,
|
||||
children: Instance["children"],
|
||||
instances: Instances,
|
||||
refs: Refs
|
||||
refs: Refs,
|
||||
formats: InlineFormat[] = []
|
||||
) => {
|
||||
for (const child of children) {
|
||||
if (child.type === "text") {
|
||||
// convert text
|
||||
if (child.value === "\n" && $isElementNode(parent)) {
|
||||
if (child.value === "\n") {
|
||||
const lineBreakNode = $createLineBreakNode();
|
||||
parent.append(lineBreakNode);
|
||||
continue;
|
||||
}
|
||||
if ($isTextNode(parent)) {
|
||||
parent.setTextContent(child.value);
|
||||
} else {
|
||||
const textNode = $createTextNode(child.value);
|
||||
parent.append(textNode);
|
||||
const textNode = $createTextNode(child.value);
|
||||
for (const { format, suffix, id } of formats) {
|
||||
if (format === "span") {
|
||||
$setNodeSpan(textNode);
|
||||
} else if (!textNode.hasFormat(format)) {
|
||||
textNode.toggleFormat(format);
|
||||
}
|
||||
refs.set(`${textNode.getKey()}:${suffix}`, id);
|
||||
}
|
||||
parent.append(textNode);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -208,40 +234,33 @@ const $writeLexical = (
|
||||
const isLinkInstance =
|
||||
instance.component === "RichTextLink" ||
|
||||
(instance.component === elementComponent && instance.tag === "a");
|
||||
if (isLinkInstance && $isElementNode(parent)) {
|
||||
if (isLinkInstance) {
|
||||
const linkNode = $createLinkNode("");
|
||||
refs.set(linkNode.getKey(), instance.id);
|
||||
for (const { suffix, id } of formats) {
|
||||
refs.set(`${linkNode.getKey()}:${suffix}`, id);
|
||||
}
|
||||
parent.append(linkNode);
|
||||
$writeLexical(linkNode, instance.children, instances, refs);
|
||||
$writeLexical(linkNode, instance.children, instances, refs, formats);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
instance.component === "Span" ||
|
||||
(instance.component === elementComponent && instance.tag === "span")
|
||||
) {
|
||||
let textNode;
|
||||
if ($isTextNode(parent)) {
|
||||
textNode = parent;
|
||||
} else {
|
||||
textNode = $createTextNode("");
|
||||
parent.append(textNode);
|
||||
}
|
||||
$setNodeSpan(textNode);
|
||||
refs.set(`${textNode.getKey()}:span`, instance.id);
|
||||
$writeLexical(textNode, instance.children, instances, refs);
|
||||
$writeLexical(parent, instance.children, instances, refs, [
|
||||
...formats,
|
||||
{ format: "span", suffix: "span", id: instance.id },
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
// convert all lexical formats
|
||||
for (const [format, component] of legacyLexicalFormats) {
|
||||
if (instance.component === component) {
|
||||
let textNode;
|
||||
if ($isTextNode(parent)) {
|
||||
textNode = parent;
|
||||
} else {
|
||||
textNode = $createTextNode("");
|
||||
parent.append(textNode);
|
||||
}
|
||||
textNode.toggleFormat(format);
|
||||
refs.set(`${textNode.getKey()}:${format}`, instance.id);
|
||||
$writeLexical(textNode, instance.children, instances, refs);
|
||||
$writeLexical(parent, instance.children, instances, refs, [
|
||||
...formats,
|
||||
{ format, suffix: format, id: instance.id },
|
||||
]);
|
||||
}
|
||||
}
|
||||
// convert all lexical formats
|
||||
@@ -251,16 +270,10 @@ const $writeLexical = (
|
||||
instance.component === elementComponent &&
|
||||
(instance.tag === tag || alias !== undefined)
|
||||
) {
|
||||
let textNode;
|
||||
if ($isTextNode(parent)) {
|
||||
textNode = parent;
|
||||
} else {
|
||||
textNode = $createTextNode("");
|
||||
parent.append(textNode);
|
||||
}
|
||||
textNode.toggleFormat(format);
|
||||
refs.set(`${textNode.getKey()}:${alias ?? format}`, instance.id);
|
||||
$writeLexical(textNode, instance.children, instances, refs);
|
||||
$writeLexical(parent, instance.children, instances, refs, [
|
||||
...formats,
|
||||
{ format, suffix: alias ?? format, id: instance.id },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { userEvent } from "@vitest/browser/context";
|
||||
import { renderData, renderTemplate, ws } from "@webstudio-is/template";
|
||||
import {
|
||||
blockComponent,
|
||||
blockTemplateComponent,
|
||||
@@ -84,6 +85,68 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("TextEditor", () => {
|
||||
test("saves edits to text containing a formatted link on blur", async () => {
|
||||
const { instances } = renderData(
|
||||
<ws.element ws:id="paragraph" ws:tag="p">
|
||||
<ws.element ws:id="emphasis" ws:tag="em">
|
||||
{"Before "}
|
||||
<ws.element ws:id="link" ws:tag="a" ws:label="Link">
|
||||
linked text
|
||||
</ws.element>
|
||||
{" after."}
|
||||
</ws.element>
|
||||
</ws.element>
|
||||
);
|
||||
$instances.set(instances);
|
||||
selectInstance(["paragraph"]);
|
||||
$textEditingInstanceSelector.set({
|
||||
selector: ["paragraph"],
|
||||
reason: "left",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<TextEditor
|
||||
rootInstanceSelector={["paragraph"]}
|
||||
instances={instances}
|
||||
props={new Map()}
|
||||
contentEditable={<ContentEditable />}
|
||||
onChange={onChange}
|
||||
onSelectInstance={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const editable = container.querySelector<HTMLElement>(
|
||||
"[data-lexical-editor]"
|
||||
)!;
|
||||
expect(editable.textContent).toBe("Before linked text after.");
|
||||
expect(editable.querySelector("a")?.getAttribute("data-ws-id")).toBe(
|
||||
"link"
|
||||
);
|
||||
await act(async () => {
|
||||
await userEvent.type(editable, " edited");
|
||||
editable.blur();
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0]?.[0]).toEqual(
|
||||
renderTemplate(
|
||||
<ws.element ws:id="paragraph" ws:tag="p">
|
||||
<ws.element ws:id="emphasis" ws:tag="em">
|
||||
{"Before "}
|
||||
<ws.element ws:id="link" ws:tag="a" ws:label="Link">
|
||||
linked text
|
||||
</ws.element>
|
||||
{" after."}
|
||||
</ws.element>
|
||||
{" edited"}
|
||||
</ws.element>
|
||||
).instances
|
||||
);
|
||||
});
|
||||
|
||||
test("focuses the editable element when initializing a new empty instance", async () => {
|
||||
const instance: Instance = {
|
||||
type: "instance",
|
||||
|
||||
@@ -135,6 +135,9 @@ const BindInstanceToNodePlugin = ({
|
||||
for (const [nodeKey, instanceId] of refs) {
|
||||
// extract key from stored key:style format
|
||||
const [key] = nodeKey.split(":");
|
||||
if (nodeKey !== key && refs.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const element = editor.getElementByKey(key);
|
||||
if (element) {
|
||||
element.setAttribute(idAttribute, instanceId);
|
||||
@@ -1673,7 +1676,12 @@ export const TextEditor = ({
|
||||
builderRuntimeContext.createId,
|
||||
transientTextNodeKeys
|
||||
);
|
||||
const idMap = onChange(updates);
|
||||
const idMap = onChange(
|
||||
updates.map((instance) => ({
|
||||
...currentInstances.get(instance.id),
|
||||
...instance,
|
||||
}))
|
||||
);
|
||||
if (idMap !== undefined) {
|
||||
for (const [key, instanceId] of refs) {
|
||||
refs.set(key, idMap[instanceId] ?? instanceId);
|
||||
|
||||
Reference in new issue
Block a user