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);