fix: Preserve nested links when editing MDX paragraphs

This commit is contained in:
Oleg Isonen committed 2026-09-09 20:06:32 +01:00
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);