diff --git a/app/canvas/features/wrapper-component/editor/editor.tsx b/app/canvas/features/wrapper-component/editor/editor.tsx index 5e387a5497..b35f4220c3 100644 --- a/app/canvas/features/wrapper-component/editor/editor.tsx +++ b/app/canvas/features/wrapper-component/editor/editor.tsx @@ -1,20 +1,23 @@ -import { type Instance } from "@webstudio-is/sdk"; +import { type ChildrenUpdates, type Instance } from "@webstudio-is/sdk"; import { RichTextPlugin, HistoryPlugin, InstancePlugin, ToolbarPlugin, + OnChangePlugin, } from "~/shared/text-editor"; type EditorProps = { editable: JSX.Element; children: Array; + onChange: (udpates: ChildrenUpdates) => void; }; -export const Editor = ({ children, editable }: EditorProps) => { +export const Editor = ({ children, editable, onChange }: EditorProps) => { return ( <> + {children} diff --git a/app/canvas/features/wrapper-component/wrapper-component.tsx b/app/canvas/features/wrapper-component/wrapper-component.tsx index 6afb68d2a3..d4f49a1954 100644 --- a/app/canvas/features/wrapper-component/wrapper-component.tsx +++ b/app/canvas/features/wrapper-component/wrapper-component.tsx @@ -13,6 +13,7 @@ import { useEnsureFocus } from "./use-ensure-focus"; import { Editor } from "./editor/editor"; import { useIsEditing } from "./editor/use-is-editing"; import { useContentEditable } from "~/shared/text-editor"; +import noop from "lodash.noop"; type WrapperComponentDevProps = { instance: Instance; @@ -25,13 +26,12 @@ export const WrapperComponentDev = ({ instance, css, children, - onChangeChildren: _onChangeChildren, + onChangeChildren = noop, ...rest }: WrapperComponentDevProps) => { const className = useCss({ instance, css }); const [isEditing, isEditingProps] = useIsEditing(instance); const [editableRefCallback, editableProps] = useContentEditable(isEditing); - const { dragRefCallback, ...draggableProps } = useDraggable({ instance, // We can't drag if we are editing text. @@ -87,7 +87,16 @@ export const WrapperComponentDev = ({ ); if (isEditing) { - return {instance.children}; + return ( + { + onChangeChildren({ instanceId: instance.id, updates }); + }} + > + {instance.children} + + ); } return element; diff --git a/app/shared/text-editor/example-text-editor.stories.tsx b/app/shared/text-editor/example-text-editor.stories.tsx index 01dbe938a5..69fdfc1ef3 100644 --- a/app/shared/text-editor/example-text-editor.stories.tsx +++ b/app/shared/text-editor/example-text-editor.stories.tsx @@ -1,31 +1,17 @@ -import React from "react"; import type { ComponentStory, ComponentMeta } from "@storybook/react"; +import { action } from "@storybook/addon-actions"; import { ExampleTextEditor } from "./example-text-editor"; import "./styles.css"; -import { $getRoot, $getSelection, type EditorState } from "./lexical"; - -// When the editor changes, you can get notified via the -// LexicalOnChangePlugin! -const onChange = (editorState: EditorState) => { - editorState.read(() => { - // Read the contents of the EditorState here. - const root = $getRoot(); - const selection = $getSelection(); - - console.log(root, selection); - }); -}; export default { title: "Text Editor", component: ExampleTextEditor, } as ComponentMeta; -const Template: ComponentStory = (args) => { +export const TextEditor: ComponentStory = (args) => { return ; }; -export const TextEditor = Template.bind({}); TextEditor.args = { - onChange, + onChange: action("onChange"), }; diff --git a/app/shared/text-editor/example-text-editor.tsx b/app/shared/text-editor/example-text-editor.tsx index 19e48c85c7..5dc0092fcb 100644 --- a/app/shared/text-editor/example-text-editor.tsx +++ b/app/shared/text-editor/example-text-editor.tsx @@ -1,19 +1,22 @@ -import { publish, useAllUserProps } from "@webstudio-is/sdk"; import { - type EditorState, + type ChildrenUpdates, + publish, + useAllUserProps, +} from "@webstudio-is/sdk"; +import { LexicalComposer, RichTextPlugin, ContentEditable, HistoryPlugin, - OnChangePlugin, } from "./lexical"; +import { OnChangePlugin } from "./plugins/plugin-on-change"; import { InstancePlugin } from "./plugins/plugin-instance"; import { TreeViewPlugin } from "./plugins/tree-view-plugin"; import { config } from "./config"; import { createInstance } from "../tree-utils"; type ExampleTextEditorProps = { - onChange: (state: EditorState) => void; + onChange: (state: ChildrenUpdates) => void; }; const Menu = () => { diff --git a/app/shared/text-editor/index.ts b/app/shared/text-editor/index.ts index f9fa3672ac..e47acb3978 100644 --- a/app/shared/text-editor/index.ts +++ b/app/shared/text-editor/index.ts @@ -3,8 +3,10 @@ export { RichTextPlugin, ContentEditable, HistoryPlugin, + type EditorState, } from "./lexical"; export { config } from "./config"; export { InstancePlugin } from "./plugins/plugin-instance"; export { ToolbarPlugin } from "./plugins/plugin-toolbar"; +export { OnChangePlugin } from "./plugins/plugin-on-change"; export { useContentEditable } from "./content-editable"; diff --git a/app/shared/text-editor/lexical.ts b/app/shared/text-editor/lexical.ts index 4529d1e464..97898220ec 100644 --- a/app/shared/text-editor/lexical.ts +++ b/app/shared/text-editor/lexical.ts @@ -3,7 +3,13 @@ export type { EditorState, LexicalCommand, LexicalEditor, + LexicalNode, RangeSelection, + SerializedEditorState, + SerializedLexicalNode, + SerializedElementNode, + SerializedTextNode, + SerializedRootNode, } from "lexical"; export { ElementNode, diff --git a/app/shared/text-editor/plugins/plugin-instance.tsx b/app/shared/text-editor/plugins/plugin-instance.tsx index c227ce87d0..1b31d7e08d 100644 --- a/app/shared/text-editor/plugins/plugin-instance.tsx +++ b/app/shared/text-editor/plugins/plugin-instance.tsx @@ -5,44 +5,23 @@ import { type Instance, toCss, } from "@webstudio-is/sdk"; +import { useCss } from "~/canvas/features/wrapper-component/use-css"; +import { useBreakpoints } from "~/shared/nano-states"; import { primitives } from "../../canvas-components"; import { $createInstanceNode, InstanceNode } from "../nodes/node-instance"; import { createCommand, $isRangeSelection, $getSelection, - $createTextNode, - $createParagraphNode, $getRoot, COMMAND_PRIORITY_EDITOR, useLexicalComposerContext, type LexicalCommand, } from "../lexical"; -import { useCss } from "~/canvas/features/wrapper-component/use-css"; -import { useBreakpoints } from "~/shared/nano-states"; +import { toLexicalNodes } from "../utils/to-lexical-nodes"; const populateRoot = (children: Instance["children"]) => { - const nodes = children.map((child) => { - if (typeof child === "string") { - const paragraph = $createParagraphNode(); - paragraph.append($createTextNode(child)); - return paragraph; - } - // @todo - const props = {}; - // Inline components should always have a single child string - const text = typeof child.children[0] === "string" ? child.children[0] : ""; - - return $createInstanceNode({ - component: ( - - {text} - - ), - text, - }); - }); - + const nodes = toLexicalNodes(children, InlineWrapperComponent); const root = $getRoot(); root.clear(); for (const node of nodes) { diff --git a/app/shared/text-editor/plugins/plugin-on-change.tsx b/app/shared/text-editor/plugins/plugin-on-change.tsx new file mode 100644 index 0000000000..cc887b1423 --- /dev/null +++ b/app/shared/text-editor/plugins/plugin-on-change.tsx @@ -0,0 +1,23 @@ +import { type ChildrenUpdates } from "@webstudio-is/sdk"; +import { + OnChangePlugin as LexicalOnChangePlugin, + type EditorState, +} from "../lexical"; +import { toUpdates } from "../utils/to-updates"; + +export const OnChangePlugin = ({ + onChange, +}: { + onChange: (updates: ChildrenUpdates) => void; +}) => { + const handleChange = (state: EditorState) => { + state.read(() => { + const updates = toUpdates(state.toJSON().root); + onChange(updates); + console.log(state.toJSON()); + console.log(updates); + }); + }; + + return ; +}; diff --git a/app/shared/text-editor/utils/to-lexical-nodes.tsx b/app/shared/text-editor/utils/to-lexical-nodes.tsx new file mode 100644 index 0000000000..f42bcefe31 --- /dev/null +++ b/app/shared/text-editor/utils/to-lexical-nodes.tsx @@ -0,0 +1,29 @@ +import { type Instance } from "@webstudio-is/sdk"; +import { $createInstanceNode } from "../nodes/node-instance"; +import { $createTextNode, $createParagraphNode } from "../lexical"; + +export const toLexicalNodes = ( + children: Instance["children"], + InlineWrapperComponent: (props: { + instance: Instance; + children: string; + }) => JSX.Element +) => { + return children.map((child) => { + if (typeof child === "string") { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(child)); + return paragraph; + } + + // Inline components should always have a single child string + const text = typeof child.children[0] === "string" ? child.children[0] : ""; + + return $createInstanceNode({ + component: ( + {text} + ), + text, + }); + }); +}; diff --git a/app/shared/text-editor/utils/to-updates.test.ts b/app/shared/text-editor/utils/to-updates.test.ts new file mode 100644 index 0000000000..f0c3e3b7b0 --- /dev/null +++ b/app/shared/text-editor/utils/to-updates.test.ts @@ -0,0 +1,55 @@ +import { toUpdates } from "./to-updates"; + +describe("toUpdates", () => { + test("two lines of text", () => { + const state = { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "a", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + }, + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: "b", + type: "text", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "paragraph", + version: 1, + }, + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1, + }, + } as const; + const updates = toUpdates(state.root); + expect(updates).toStrictEqual(["a\nb"]); + }); +}); diff --git a/app/shared/text-editor/utils/to-updates.ts b/app/shared/text-editor/utils/to-updates.ts new file mode 100644 index 0000000000..e87252c621 --- /dev/null +++ b/app/shared/text-editor/utils/to-updates.ts @@ -0,0 +1,38 @@ +import { type ChildrenUpdates } from "@webstudio-is/sdk"; +import { + OnChangePlugin as LexicalOnChangePlugin, + type EditorState, + type SerializedEditorState, + type SerializedTextNode, + type SerializedElementNode, + type SerializedRootNode, + type SerializedLexicalNode, +} from "../lexical"; + +type Node = + | SerializedRootNode + | SerializedElementNode + | SerializedTextNode + | SerializedLexicalNode; + +export const toUpdates = ( + node: Node, + updates: ChildrenUpdates = [] +): ChildrenUpdates => { + if ("text" in node) { + const last = updates[updates.length - 1]; + if (typeof last === "string") { + // Every new Paragraph node is basically just a new line. + updates[updates.length - 1] = `${last}\n${node.text}`; + } else { + updates.push(node.text); + } + } + + if ("children" in node) { + for (const child of node.children) { + toUpdates(child, updates); + } + } + return updates; +};