From 42a16db02c82e44623a6cd802c19aaa1ace99107 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Wed, 9 Sep 2026 20:06:32 +0100 Subject: [PATCH] fix: Preserve nested links when editing MDX paragraphs --- .../features/text-editor/interop.test.tsx | 61 ++++++ .../canvas/features/text-editor/interop.ts | 181 ++++++++++-------- .../features/text-editor/text-editor.test.tsx | 63 ++++++ .../features/text-editor/text-editor.tsx | 10 +- 4 files changed, 230 insertions(+), 85 deletions(-) diff --git a/apps/builder/app/canvas/features/text-editor/interop.test.tsx b/apps/builder/app/canvas/features/text-editor/interop.test.tsx index 25bb29dcaa..03cd1cb6f5 100644 --- a/apps/builder/app/canvas/features/text-editor/interop.test.tsx +++ b/apps/builder/app/canvas/features/text-editor/interop.test.tsx @@ -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( + + + {"Before "} + + linked text + + {" after."} + + + ); + const refs: Refs = new Map(); + const editor = createHeadlessEditor({ nodes: [LinkNode] }); + await new Promise((resolve) => { + editor.update(() => $convertToLexical(instances, "paragraph", refs), { + onUpdate: resolve, + }); + }); + expect( + editor.getEditorState().read(() => $getRoot().getTextContent()) + ).toBe("Before linked text after."); + await new Promise((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( + + + {"Before "} + + edited link + + {" after."} + + + ).instances; + expect(updates).toEqual(expected); + } +); diff --git a/apps/builder/app/canvas/features/text-editor/interop.ts b/apps/builder/app/canvas/features/text-editor/interop.ts index 847456577f..b0735f6ac1 100644 --- a/apps/builder/app/canvas/features/text-editor/interop.ts +++ b/apps/builder/app/canvas/features/text-editor/interop.ts @@ -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 + transientTextNodeKeys: ReadonlySet, + inheritedFormats: ReadonlySet = 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 }, + ]); } } } diff --git a/apps/builder/app/canvas/features/text-editor/text-editor.test.tsx b/apps/builder/app/canvas/features/text-editor/text-editor.test.tsx index 1292d416b4..3b5add8376 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.test.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.test.tsx @@ -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( + + + {"Before "} + + linked text + + {" after."} + + + ); + $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( + } + onChange={onChange} + onSelectInstance={() => {}} + /> + ); + }); + const editable = container.querySelector( + "[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( + + + {"Before "} + + linked text + + {" after."} + + {" edited"} + + ).instances + ); + }); + test("focuses the editable element when initializing a new empty instance", async () => { const instance: Instance = { type: "instance", diff --git a/apps/builder/app/canvas/features/text-editor/text-editor.tsx b/apps/builder/app/canvas/features/text-editor/text-editor.tsx index eaf89bf273..960e5a470c 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -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);