diff --git a/apps/builder/app/canvas/features/text-editor/interop.test.ts b/apps/builder/app/canvas/features/text-editor/interop.test.ts deleted file mode 100644 index 7ccc209b397f..000000000000 --- a/apps/builder/app/canvas/features/text-editor/interop.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { test, expect } from "vitest"; -import { createHeadlessEditor } from "@lexical/headless"; -import { LinkNode } from "@lexical/link"; -import type { Instance } from "@webstudio-is/sdk"; -import { $convertToLexical, $convertToUpdates, type Refs } from "./interop"; - -const createInstance = ( - id: Instance["id"], - component: string, - children: Instance["children"] -) => { - return { - type: "instance", - id, - component, - children, - }; -}; - -const createInstancePair = ( - id: Instance["id"], - component: string, - children: Instance["children"] -): [Instance["id"], Instance] => { - return [ - id, - { - type: "instance", - id, - component, - children, - }, - ]; -}; - -const instances = new Map([ - createInstancePair("1", "Body", [ - { type: "id", value: "2" }, - { type: "id", value: "3" }, - ]), - createInstancePair("2", "Box", []), - createInstancePair("3", "Box", [ - { type: "text", value: "Hello" }, - { type: "text", value: "\n" }, - { type: "id", value: "4" }, - { type: "text", value: "\n" }, - { type: "id", value: "6" }, - { type: "text", value: "\n" }, - { type: "id", value: "7" }, - ]), - createInstancePair("4", "Bold", [{ type: "id", value: "5" }]), - createInstancePair("5", "Italic", [{ type: "text", value: "world" }]), - createInstancePair("6", "Span", [{ type: "text", value: "and" }]), - createInstancePair("7", "RichTextLink", [ - { type: "text", value: "other realms" }, - ]), -]); - -const expectedRefs = new Map([ - ["4:bold", "4"], - ["4:italic", "5"], - ["6:span", "6"], - ["8", "7"], -]); - -test("convert instances to lexical", async () => { - const refs: Refs = new Map(); - const editor = createHeadlessEditor({ - nodes: [LinkNode], - }); - await new Promise((resolve) => { - editor.update( - () => { - $convertToLexical(instances, "3", refs); - }, - { onUpdate: resolve } - ); - }); - - expect(editor.getEditorState().toJSON()).toMatchInlineSnapshot(` - { - "root": { - "children": [ - { - "children": [ - { - "detail": 0, - "format": 0, - "mode": "normal", - "style": "", - "text": "Hello", - "type": "text", - "version": 1, - }, - { - "type": "linebreak", - "version": 1, - }, - { - "detail": 0, - "format": 3, - "mode": "normal", - "style": "", - "text": "world", - "type": "text", - "version": 1, - }, - { - "type": "linebreak", - "version": 1, - }, - { - "detail": 0, - "format": 0, - "mode": "normal", - "style": "--style-node-trigger: 1;", - "text": "and", - "type": "text", - "version": 1, - }, - { - "type": "linebreak", - "version": 1, - }, - { - "children": [ - { - "detail": 0, - "format": 0, - "mode": "normal", - "style": "", - "text": "other realms", - "type": "text", - "version": 1, - }, - ], - "direction": null, - "format": "", - "indent": 0, - "rel": null, - "target": null, - "title": null, - "type": "link", - "url": "", - "version": 1, - }, - ], - "direction": null, - "format": "", - "indent": 0, - "textFormat": 0, - "textStyle": "", - "type": "paragraph", - "version": 1, - }, - ], - "direction": null, - "format": "", - "indent": 0, - "type": "root", - "version": 1, - }, - } - `); - - expect(refs).toEqual(expectedRefs); -}); - -test("convert lexical to instances updates", async () => { - const refs: Refs = new Map(); - const editor = createHeadlessEditor({ - nodes: [LinkNode], - }); - await new Promise((resolve) => { - editor.update( - () => { - $convertToLexical(instances, "3", refs); - }, - { onUpdate: resolve } - ); - }); - const treeRootInstance = instances.get("3"); - if (treeRootInstance === undefined) { - throw Error("Tree root instance should be in test data"); - } - - const updates = editor.getEditorState().read(() => { - return $convertToUpdates(treeRootInstance, refs, new Map()); - }); - - expect(updates).toEqual([ - createInstance("3", "Box", [ - { type: "text", value: "Hello" }, - { type: "text", value: "\n" }, - { type: "id", value: "4" }, - { type: "text", value: "\n" }, - { type: "id", value: "6" }, - { type: "text", value: "\n" }, - { type: "id", value: "7" }, - ]), - createInstance("4", "Bold", [{ type: "id", value: "5" }]), - createInstance("5", "Italic", [{ type: "text", value: "world" }]), - createInstance("6", "Span", [{ type: "text", value: "and" }]), - createInstance("7", "RichTextLink", [ - { type: "text", value: "other realms" }, - ]), - ]); -}); diff --git a/apps/builder/app/canvas/features/text-editor/interop.test.tsx b/apps/builder/app/canvas/features/text-editor/interop.test.tsx new file mode 100644 index 000000000000..2e2fc5239c19 --- /dev/null +++ b/apps/builder/app/canvas/features/text-editor/interop.test.tsx @@ -0,0 +1,215 @@ +import { test, expect } from "vitest"; +import { createHeadlessEditor } from "@lexical/headless"; +import { LinkNode } from "@lexical/link"; +import { $, renderData, renderTemplate, ws } from "@webstudio-is/template"; +import { $convertToLexical, $convertToUpdates, type Refs } from "./interop"; + +const { instances } = renderData( + <$.Body ws:id="bodyId"> + <$.Box ws:id="emptyBoxId"> + <$.Box ws:id="textBoxId"> + Hello{"\n"} + <$.Bold ws:id="boldId"> + <$.Italic ws:id="italicId">world + + {"\n"} + <$.Span ws:id="spanId">and + {"\n"} + <$.RichTextLink ws:id="linkId" href="/my-url"> + other realms + + + + Hello{"\n"} + + + world + + + {"\n"} + + and + + {"\n"} + + other realms + + + +); + +const expectedState = { + root: expect.objectContaining({ + type: "root", + children: [ + expect.objectContaining({ + type: "paragraph", + children: [ + expect.objectContaining({ + type: "text", + format: 0, + style: "", + text: "Hello", + }), + expect.objectContaining({ type: "linebreak" }), + expect.objectContaining({ + type: "text", + format: 3, + style: "", + text: "world", + }), + expect.objectContaining({ type: "linebreak" }), + expect.objectContaining({ + type: "text", + format: 0, + style: "--style-node-trigger: 1;", + text: "and", + }), + expect.objectContaining({ type: "linebreak" }), + expect.objectContaining({ + type: "link", + format: "", + rel: null, + target: null, + title: null, + url: "", + children: [ + expect.objectContaining({ + type: "text", + format: 0, + style: "", + text: "other realms", + }), + ], + }), + ], + }), + ], + }), +}; + +test("convert legacy instances to lexical", async () => { + const refs: Refs = new Map(); + const editor = createHeadlessEditor({ + nodes: [LinkNode], + }); + await new Promise((resolve) => { + editor.update( + () => { + $convertToLexical(instances, "textBoxId", refs); + }, + { onUpdate: resolve } + ); + }); + expect(editor.getEditorState().toJSON()).toEqual(expectedState); + expect(refs).toEqual( + new Map([ + ["4:bold", "boldId"], + ["4:italic", "italicId"], + ["6:span", "spanId"], + ["8", "linkId"], + ]) + ); +}); + +test("convert element instances to lexical", async () => { + const refs: Refs = new Map(); + const editor = createHeadlessEditor({ + nodes: [LinkNode], + }); + await new Promise((resolve) => { + editor.update( + () => { + $convertToLexical(instances, "textElementId", refs); + }, + { onUpdate: resolve } + ); + }); + expect(editor.getEditorState().toJSON()).toEqual(expectedState); + expect(refs).toEqual( + new Map([ + ["13:bold", "boldElementId"], + ["13:italic", "italicElementId"], + ["15:span", "spanElementId"], + ["17", "linkElementId"], + ]) + ); +}); + +test("convert lexical to legacy instances updates", async () => { + const refs: Refs = new Map(); + const editor = createHeadlessEditor({ + nodes: [LinkNode], + }); + await new Promise((resolve) => { + editor.update( + () => { + $convertToLexical(instances, "textBoxId", refs); + }, + { onUpdate: resolve } + ); + }); + const treeRootInstance = instances.get("textBoxId"); + if (treeRootInstance === undefined) { + throw Error("Tree root instance should be in test data"); + } + const updates = editor.getEditorState().read(() => { + return $convertToUpdates(treeRootInstance, refs, new Map()); + }); + expect(updates).toEqual( + renderTemplate( + <$.Box ws:id="textBoxId"> + Hello{"\n"} + <$.Bold ws:id="boldId"> + <$.Italic ws:id="italicId">world + + {"\n"} + <$.Span ws:id="spanId">and + {"\n"} + <$.RichTextLink ws:id="linkId">other realms + + ).instances + ); +}); + +test("convert lexical to element instances updates", async () => { + const refs: Refs = new Map(); + const editor = createHeadlessEditor({ + nodes: [LinkNode], + }); + await new Promise((resolve) => { + editor.update( + () => { + $convertToLexical(instances, "textElementId", refs); + }, + { onUpdate: resolve } + ); + }); + const treeRootInstance = instances.get("textElementId"); + if (treeRootInstance === undefined) { + throw Error("Tree root instance should be in test data"); + } + const updates = editor.getEditorState().read(() => { + return $convertToUpdates(treeRootInstance, refs, new Map()); + }); + expect(updates).toEqual( + renderTemplate( + + Hello{"\n"} + + + world + + + {"\n"} + + and + + {"\n"} + + other realms + + + ).instances + ); +}); diff --git a/apps/builder/app/canvas/features/text-editor/interop.ts b/apps/builder/app/canvas/features/text-editor/interop.ts index 1127cecb32c4..5354e255a5eb 100644 --- a/apps/builder/app/canvas/features/text-editor/interop.ts +++ b/apps/builder/app/canvas/features/text-editor/interop.ts @@ -12,25 +12,37 @@ import { $isLineBreakNode, } from "lexical"; import { $createLinkNode, $isLinkNode } from "@lexical/link"; -import type { Instance, Instances } from "@webstudio-is/sdk"; +import { + elementComponent, + type Instance, + type Instances, +} from "@webstudio-is/sdk"; import { $isSpanNode, $setNodeSpan } from "./toolbar-connector"; // Map export type Refs = Map; -const lexicalFormats = [ +const legacyLexicalFormats = [ ["bold", "Bold"], ["italic", "Italic"], ["superscript", "Superscript"], ["subscript", "Subscript"], ] as const; +const elementLexicalFormats = [ + ["bold", "b"], + ["italic", "i"], + ["superscript", "sup"], + ["subscript", "sub"], +] as const; + const $writeUpdates = ( node: ElementNode, instanceChildren: Instance["children"], instancesList: Instance[], refs: Refs, - newLinkKeyToInstanceId: Refs + newLinkKeyToInstanceId: Refs, + isElement: boolean ) => { const children = node.getChildren(); for (const child of children) { @@ -40,7 +52,8 @@ const $writeUpdates = ( instanceChildren, instancesList, refs, - newLinkKeyToInstanceId + newLinkKeyToInstanceId, + isElement ); } if ($isLineBreakNode(child)) { @@ -60,14 +73,25 @@ const $writeUpdates = ( childChildren, instancesList, refs, - newLinkKeyToInstanceId + newLinkKeyToInstanceId, + isElement ); - instancesList.push({ - type: "instance", - id, - component: "RichTextLink", - children: childChildren, - }); + if (isElement) { + instancesList.push({ + type: "instance", + id, + component: elementComponent, + tag: "a", + children: childChildren, + }); + } else { + instancesList.push({ + type: "instance", + id, + component: "RichTextLink", + children: childChildren, + }); + } } if ($isTextNode(child)) { // support nesting bold into italic and vice versa @@ -80,31 +104,61 @@ const $writeUpdates = ( const key = `${child.getKey()}:span`; const id = refs.get(key) ?? nanoid(); refs.set(key, id); - const childInstance: Instance = { - type: "instance", - id, - component: "Span", - children: [], - }; - instancesList.push(childInstance); + const childChildren: Instance["children"] = []; + if (isElement) { + instancesList.push({ + type: "instance", + id, + component: elementComponent, + tag: "span", + children: childChildren, + }); + } else { + instancesList.push({ + type: "instance", + id, + component: "Span", + children: childChildren, + }); + } parentUpdates.push({ type: "id", value: id }); - parentUpdates = childInstance.children; + parentUpdates = childChildren; } // convert all lexical formats - for (const [format, component] of lexicalFormats) { - if (child.hasFormat(format)) { - const key = `${child.getKey()}:${format}`; - const id = refs.get(key) ?? nanoid(); - refs.set(key, id); - const childInstance: Instance = { - type: "instance", - id, - component, - children: [], - }; - instancesList.push(childInstance); - parentUpdates.push({ type: "id", value: id }); - parentUpdates = childInstance.children; + if (isElement) { + for (const [format, tag] of elementLexicalFormats) { + if (child.hasFormat(format)) { + const key = `${child.getKey()}:${format}`; + const id = refs.get(key) ?? nanoid(); + 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; + } + } + } else { + for (const [format, component] of legacyLexicalFormats) { + if (child.hasFormat(format)) { + const key = `${child.getKey()}:${format}`; + const id = refs.get(key) ?? nanoid(); + refs.set(key, id); + const childInstance: Instance = { + type: "instance", + id, + component, + children: [], + }; + instancesList.push(childInstance); + parentUpdates.push({ type: "id", value: id }); + parentUpdates = childInstance.children; + } } } parentUpdates.push({ type: "text", value: text }); @@ -130,7 +184,8 @@ export const $convertToUpdates = ( treeRootInstanceChildren, instancesList, refs, - newLinkKeyToInstanceId + newLinkKeyToInstanceId, + treeRootInstance.component === elementComponent ); return instancesList; }; @@ -164,13 +219,19 @@ const $writeLexical = ( } // convert instances - if (instance.component === "RichTextLink" && $isElementNode(parent)) { + const isLinkInstance = + instance.component === "RichTextLink" || + (instance.component === elementComponent && instance.tag === "a"); + if (isLinkInstance && $isElementNode(parent)) { const linkNode = $createLinkNode(""); refs.set(linkNode.getKey(), instance.id); parent.append(linkNode); $writeLexical(linkNode, instance.children, instances, refs); } - if (instance.component === "Span") { + if ( + instance.component === "Span" || + (instance.component === elementComponent && instance.tag === "span") + ) { let textNode; if ($isTextNode(parent)) { textNode = parent; @@ -183,7 +244,7 @@ const $writeLexical = ( $writeLexical(textNode, instance.children, instances, refs); } // convert all lexical formats - for (const [format, component] of lexicalFormats) { + for (const [format, component] of legacyLexicalFormats) { if (instance.component === component) { let textNode; if ($isTextNode(parent)) { @@ -197,6 +258,21 @@ const $writeLexical = ( $writeLexical(textNode, instance.children, instances, refs); } } + // convert all lexical formats + for (const [format, tag] of elementLexicalFormats) { + if (instance.component === elementComponent && instance.tag === tag) { + 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); + } + } } };