diff --git a/packages/core/src/_internals.ts b/packages/core/src/_internals.ts index 45bc630f..47d75e72 100644 --- a/packages/core/src/_internals.ts +++ b/packages/core/src/_internals.ts @@ -39,3 +39,7 @@ export { } from "./components/ComponentBuilder/ComponentBuilder"; export { createTestCompilationContext, createFormMock } from "./testUtils"; export * from "./compiler/builtins/$richText/builders"; +export { + EasyblocksCanvasProvider, + useEasyblocksCanvasContext, +} from "./components/EasyblocksCanvasProvider"; diff --git a/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx b/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx index 168eb913..ec5995c9 100644 --- a/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx +++ b/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx @@ -2,7 +2,6 @@ import { deepClone, deepCompare, dotNotationGet } from "@easyblocks/utils"; import throttle from "lodash/throttle"; import React, { - useCallback, useEffect, useLayoutEffect, useMemo, @@ -26,6 +25,7 @@ import type { RenderPlaceholderProps, } from "slate-react"; import { Editable, ReactEditor, Slate, withReact } from "slate-react"; +import { useEasyblocksCanvasContext } from "../../../_internals"; import { Box } from "../../../components/Box/Box"; import { ComponentBuilder, @@ -70,16 +70,39 @@ interface RichTextProps extends InternalNoCodeComponentProps { } function RichTextEditor(props: RichTextProps) { - const { editorContext } = (window.parent as any).editorWindowAPI; + // const { editorContext } = (window.parent as any).editorWindowAPI; + + const canvasContext = useEasyblocksCanvasContext()!; const { - actions, - contextParams, - form, - focussedField, + formValues, locales, - setFocussedField, - } = editorContext; + locale, + focussedField, + definitions, + components, + } = canvasContext; + + // const { + // actions, + // contextParams, + // form, + // focussedField, + // locales, + // setFocussedField, + // } = editorContext; + + const setFocussedField = (field: Array | string) => { + window.parent.postMessage( + { + type: "@easyblocks-editor/focus", + payload: { + target: field, + }, + }, + "*" + ); + }; const { __easyblocks: { @@ -90,25 +113,24 @@ function RichTextEditor(props: RichTextProps) { } = props; let richTextConfig: RichTextComponentConfig = dotNotationGet( - form.values, + formValues, path ); const [editor] = useState(() => withEasyblocks(withReact(createEditor()))); - const localizedRichTextElements = - richTextConfig.elements[contextParams.locale]; + const localizedRichTextElements = richTextConfig.elements[locale]; const fallbackRichTextElements = getFallbackForLocale( richTextConfig.elements, - contextParams.locale, + locale, locales ); const richTextElements = localizedRichTextElements ?? fallbackRichTextElements; - const richTextElementsConfigPath = `${path}.elements.${contextParams.locale}`; + const richTextElementsConfigPath = `${path}.elements.${locale}`; const [editorValue, setEditorValue] = useState(() => convertRichTextElementsToEditorValue(richTextElements) @@ -120,8 +142,9 @@ function RichTextEditor(props: RichTextProps) { // We only want to show rich text for default config within this component, we don't want to update raw content // To prevent implicit update of raw content we make a deep copy. richTextConfig = deepClone(richTextConfig); - richTextConfig.elements[contextParams.locale] = - convertEditorValueToRichTextElements(editorValue); + richTextConfig.elements[locale] = convertEditorValueToRichTextElements( + editor.children as Array + ); } /** @@ -141,71 +164,57 @@ function RichTextEditor(props: RichTextProps) { ); /** - * Whether the content editable is enabled or not. We enable it through double click. + * Whether the content editable is enabled or not. We enable it through double click and disable when + * the focused field changes to different component than rich text or any of its ancestors. */ const [isEnabled, setIsEnabled] = useState(false); const previousRichTextComponentConfig = useRef(); - const currentSelectionRef = useRef(null); + const previousCompiledRichText = useRef(); - const isConfigChanged = !isConfigEqual( - previousRichTextComponentConfig.current, - richTextConfig - ); - - if (previousRichTextComponentConfig.current && isConfigChanged) { - if (lastChangeReason.current !== "paste") { - lastChangeReason.current = "external"; - } + const stringifiedRichTextConfig = JSON.stringify(richTextConfig); + const isRichTextActive = focussedField.some((f) => f.startsWith(path)); + if ( + !previousRichTextComponentConfig.current || + (!deepCompare(richTextConfig, previousRichTextComponentConfig.current) && + (lastChangeReason.current === "external" || + lastChangeReason.current === "paste")) + ) { previousRichTextComponentConfig.current = richTextConfig; const nextEditorValue = convertRichTextElementsToEditorValue(richTextElements); - // React bails out the render if state setter function is invoked during the render phase. - // Doing it makes Slate always up-to date with the latest config if it's changed from outside. - // https://reactjs.org/docs/hooks-faq.html#how-do-i-implement-getderivedstatefromprops - setEditorValue(nextEditorValue); editor.children = nextEditorValue; if (isEnabled) { const newEditorSelection = getEditorSelectionFromFocusedFields( focussedField, - form + formValues ); - if (isDecorationActive) { - currentSelectionRef.current = newEditorSelection; + if (newEditorSelection !== null) { + Transforms.select(editor, newEditorSelection); } else { - // Slate gives us two methods to update its selection: - // - `setSelection` updates current selection, so `editor.selection` must be not null - // - `select` sets the selection, so `editor.selection` must be null - if (newEditorSelection !== null && editor.selection !== null) { - Transforms.setSelection(editor, newEditorSelection); - } else if (newEditorSelection !== null && editor.selection === null) { - Transforms.select(editor, newEditorSelection); - } else { - Transforms.deselect(editor); - } + Transforms.deselect(editor); } } + + // We set it only to trigger re-render of Slate after we've updated `editor.children` + setEditorValue(nextEditorValue); } useLayoutEffect(() => { if ( isDecorationActive && - currentSelectionRef.current !== null && - !Range.isCollapsed(currentSelectionRef.current) + editor.selection && + !Range.isCollapsed(editor.selection) ) { - splitStringNodes(editor, currentSelectionRef.current); + splitStringNodes(editor, editor.selection); return () => { unwrapStringNodesContent(editor); }; } - }, [editor, isDecorationActive, richTextConfig]); - - const isRichTextActive = focussedField.some((focusedField: any) => - focusedField.startsWith(path) - ); + }, [editor, isDecorationActive, stringifiedRichTextConfig]); useLayoutEffect(() => { // When rich text becomes inactive we want to restore all original [data-slate-string] nodes @@ -215,20 +224,14 @@ function RichTextEditor(props: RichTextProps) { } }, [editor, isRichTextActive]); - useEffect(() => { - // We set previous value of rich text only once, then we manually assign it when needed. - previousRichTextComponentConfig.current = richTextConfig; - }, []); - useEffect( // Component is blurred when the user selects other component in editor. This is different from blurring content editable. // Content editable can be blurred, but the component can remain active ex. when we select some text within content editable // and want to update its color from the sidebar. function handleRichTextBlur() { if (!isRichTextActive && isEnabled) { - // editor.children = deepClone(editorValue); setIsEnabled(false); - currentSelectionRef.current = null; + lastChangeReason.current = "external"; } if (!editor.selection) { @@ -246,11 +249,21 @@ function RichTextEditor(props: RichTextProps) { // if the fallback value is present. if (isSlateValueEmpty && fallbackRichTextElements !== undefined) { const nextRichTextElement = deepClone(richTextConfig); - delete nextRichTextElement.elements[contextParams.locale]; + delete nextRichTextElement.elements[locale]; editor.children = convertRichTextElementsToEditorValue( fallbackRichTextElements ); - form.change(path, nextRichTextElement); + + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: nextRichTextElement, + }, + }, + "*" + ); } } }, @@ -266,14 +279,12 @@ function RichTextEditor(props: RichTextProps) { useEffect(() => { function handleRichTextChanged(event: RichTextChangedEvent) { - if (!editor.selection) { - return; - } - if (event.data.type === "@easyblocks-editor/rich-text-changed") { - const { payload } = event.data; - const { editorContext } = (window.parent as any).editorWindowAPI; + if (!editor.selection) { + return; + } + const { payload } = event.data; // Slate is an uncontrolled component and we don't have an easy access to control it. // It keeps its state internally and on each change we convert this state to our format. // This works great because changing content of editable element is easy, we append or remove things. @@ -297,32 +308,30 @@ function RichTextEditor(props: RichTextProps) { return; } - currentSelectionRef.current = temporaryEditor.selection; - - actions.runChange(() => { - const newRichTextElement: RichTextComponentConfig = { - ...richTextConfig, - elements: { - ...richTextConfig.elements, - [editorContext.contextParams.locale]: - updateSelectionResult.elements, - }, - }; - - form.change(path, newRichTextElement); + const newRichTextElement: RichTextComponentConfig = { + ...richTextConfig, + elements: { + ...richTextConfig.elements, + [locale]: updateSelectionResult.elements, + }, + }; - const newFocusedFields = - updateSelectionResult.focusedRichTextParts.map( - (focusedRichTextPart) => - getAbsoluteRichTextPartPath( - focusedRichTextPart, - path, - editorContext.contextParams.locale - ) - ); + const newFocusedFields = updateSelectionResult.focusedRichTextParts.map( + (focusedRichTextPart) => + getAbsoluteRichTextPartPath(focusedRichTextPart, path, locale) + ); - return newFocusedFields; - }); + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: newRichTextElement, + focussedField: newFocusedFields, + }, + }, + "*" + ); } } @@ -331,7 +340,7 @@ function RichTextEditor(props: RichTextProps) { return () => { window.removeEventListener("message", handleRichTextChanged); }; - }, [richTextConfig, path]); + }, [path, editor, richTextConfig, locale]); const decorate = createTextSelectionDecorator(editor); const Elements = extractElementsFromCompiledComponents(props); @@ -341,7 +350,7 @@ function RichTextEditor(props: RichTextProps) { children, element, }: RenderElementProps) { - const Element = Elements.find( + let Element = Elements.find( (Element) => Element._id === element.id || NORMALIZED_IDS_TO_IDS.get(element.id) === Element._id @@ -361,7 +370,21 @@ function RichTextEditor(props: RichTextProps) { return
{children}
; } - throw new Error("Missing element"); + if (previousCompiledRichText.current) { + const PreviousElements = extractElementsFromCompiledComponents( + previousCompiledRichText.current + ); + + Element = PreviousElements.find( + (Element) => + Element._id === element.id || + NORMALIZED_IDS_TO_IDS.get(element.id) === Element._id + ); + } + + if (!Element) { + throw new Error("Missing element"); + } } const compiledStyles = (() => { @@ -422,7 +445,21 @@ function RichTextEditor(props: RichTextProps) { return {children}; } - throw new Error("Missing part"); + if (previousCompiledRichText.current) { + const PreviousTextParts = extractTextPartsFromCompiledComponents( + previousCompiledRichText.current + ); + + TextPart = PreviousTextParts.find( + (TextPart) => + TextPart._id === leaf.id || + NORMALIZED_IDS_TO_IDS.get(leaf.id) === TextPart._id + ); + } + + if (!TextPart) { + throw new Error("Missing part"); + } } const TextPartComponent = ( @@ -441,7 +478,7 @@ function RichTextEditor(props: RichTextProps) { ) => { - setEditorValue(nextValue); + const scheduleConfigSync = throttle( + (nextValue: Array, editor: Editor) => { const nextElements = convertEditorValueToRichTextElements(nextValue); - actions.runChange(() => { - const newRichTextElement: RichTextComponentConfig = { - ...richTextConfig, - elements: { - ...richTextConfig.elements, - [editorContext.contextParams.locale]: nextElements, - }, - }; - - form.change(path, newRichTextElement); - previousRichTextComponentConfig.current = newRichTextElement; - - if (editor.selection) { - const nextFocusedFields = getFocusedFieldsFromSlateSelection( - editor, - path, - contextParams.locale - ); + const newRichTextElement: RichTextComponentConfig = { + ...richTextConfig, + elements: { + ...richTextConfig.elements, + [locale]: nextElements, + }, + }; - return nextFocusedFields; - } - }); - }, RICH_TEXT_CONFIG_SYNC_THROTTLE_TIMEOUT), - [isConfigChanged, editorContext.contextParams.locale] + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: newRichTextElement, + ...(editor.selection + ? { + focussedField: getFocusedFieldsFromSlateSelection( + editor, + path, + locale + ), + } + : {}), + }, + }, + "*" + ); + }, + RICH_TEXT_CONFIG_SYNC_THROTTLE_TIMEOUT ); - const scheduleFocusedFieldsChange = useCallback( - // Slate internally throttles the invocation of DOMSelectionChange for performance reasons. - // We also throttle update of our focused fields state for the same reason. - // This gives us a good balance between perf and showing updated fields within the sidebar. - throttle((focusedFields: Parameters[0]) => { + // Slate internally throttles the invocation of DOMSelectionChange for performance reasons. + // We also throttle update of our focused fields state for the same reason. + // This gives us a good balance between perf and showing updated fields within the sidebar. + const scheduleFocusedFieldsChange = throttle( + (focusedFields: Parameters[0]) => { setFocussedField(focusedFields); - }, RICH_TEXT_FOCUSED_FIELDS_SYNC_THROTTLE_TIMEOUT), - [setFocussedField] + }, + RICH_TEXT_FOCUSED_FIELDS_SYNC_THROTTLE_TIMEOUT ); function handleEditableChange(value: Array): void { @@ -518,17 +559,6 @@ function RichTextEditor(props: RichTextProps) { return; } - // Editor's value can be changed from outside ex. sidebar or history undo/redo. If the last reason for change - // was "external", we skip this change. In case we would like to start typing immediately after undo/redo we - // set last change reason to `text-input`. - if ( - lastChangeReason.current === "external" || - lastChangeReason.current === "paste" - ) { - lastChangeReason.current = "text-input"; - return; - } - const isValueSame = deepCompare(value, editorValue); // Slate runs `onChange` callback on any change, even when the text haven't changed. @@ -537,7 +567,7 @@ function RichTextEditor(props: RichTextProps) { const nextFocusedFields = getFocusedFieldsFromSlateSelection( editor, path, - contextParams.locale + locale ); if (nextFocusedFields) { @@ -548,7 +578,9 @@ function RichTextEditor(props: RichTextProps) { } lastChangeReason.current = "text-input"; - scheduleConfigSync(value as Array); + + scheduleConfigSync.cancel(); + scheduleConfigSync(value as Array, editor); } function handleEditableFocus(): void { @@ -570,7 +602,7 @@ function RichTextEditor(props: RichTextProps) { fallbackRichTextElements[0].elements[0].elements[0]; // Keep only one line element with single empty rich text - nextRichTextComponentConfig.elements[contextParams.locale] = [ + nextRichTextComponentConfig.elements[locale] = [ { ...fallbackRichTextElements[0], elements: [ @@ -588,7 +620,7 @@ function RichTextEditor(props: RichTextProps) { ]; nextSlateValue = convertRichTextElementsToEditorValue( - nextRichTextComponentConfig.elements[contextParams.locale] + nextRichTextComponentConfig.elements[locale] ); editor.children = nextSlateValue; @@ -598,18 +630,39 @@ function RichTextEditor(props: RichTextProps) { focus: Editor.start(editor, []), }); - form.change(path, nextRichTextComponentConfig); + // form.change(path, nextRichTextComponentConfig); + + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: nextRichTextComponentConfig, + }, + }, + "*" + ); } else { // If current and fallback value is missing we have: // - empty Slate value // - empty config within component-collection-localised // We will build next $richText component config based on current Slate value nextRichTextComponentConfig = richTextConfig; - nextRichTextComponentConfig.elements[contextParams.locale] = + nextRichTextComponentConfig.elements[locale] = convertEditorValueToRichTextElements( editor.children as Array ); - form.change(path, nextRichTextComponentConfig); + // form.change(path, nextRichTextComponentConfig); + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: nextRichTextComponentConfig, + }, + }, + "*" + ); } previousRichTextComponentConfig.current = nextRichTextComponentConfig; @@ -618,11 +671,7 @@ function RichTextEditor(props: RichTextProps) { const nextFocusedFields = getFocusedRichTextPartsConfigPaths( editor ).map((richTextPartPath) => - getAbsoluteRichTextPartPath( - richTextPartPath, - path, - contextParams.locale - ) + getAbsoluteRichTextPartPath(richTextPartPath, path, locale) ); setFocussedField(nextFocusedFields); @@ -639,39 +688,10 @@ function RichTextEditor(props: RichTextProps) { } useEffect(() => { - function saveLatestSelection() { - const root = ReactEditor.findDocumentOrShadowRoot(editor); - const selection = (root as Document).getSelection(); - - if (selection && selection.type === "Range") { - currentSelectionRef.current = ReactEditor.toSlateRange( - editor, - selection, - { exactMatch: false, suppressThrow: true } - ); - } else { - currentSelectionRef.current = null; - } - } - - const throttledSaveLatestSelection = throttle(saveLatestSelection, 100); - - if (isEnabled) { - window.document.addEventListener( - "selectionchange", - throttledSaveLatestSelection - ); - - return () => { - window.document.removeEventListener( - "selectionchange", - throttledSaveLatestSelection - ); - }; - } - }, [editor, isEnabled]); + previousCompiledRichText.current = props; + }); - function handleEditableBlur(): void { + function handleEditableBlur() { lastChangeReason.current = "external"; setIsDecorationActive(true); } @@ -682,7 +702,10 @@ function RichTextEditor(props: RichTextProps) { function handleEditableCopy(event: React.ClipboardEvent) { const selectedRichTextComponentConfig = getRichTextComponentConfigFragment( richTextConfig, - editorContext + focussedField, + locale, + formValues, + definitions ); event.clipboardData.setData( @@ -692,19 +715,21 @@ function RichTextEditor(props: RichTextProps) { } function handleEditablePaste(event: React.ClipboardEvent) { - const selectedRichTextComponentConfigClipboardData = + const richTextComponentConfigClipboardData = event.clipboardData.getData("text/x-shopstory"); - if (selectedRichTextComponentConfigClipboardData) { + if (richTextComponentConfigClipboardData) { + lastChangeReason.current = "paste"; + const selectedRichTextComponentConfig: RichTextComponentConfig = - JSON.parse(selectedRichTextComponentConfigClipboardData); + JSON.parse(richTextComponentConfigClipboardData); // Preventing the default action will also prevent Slate from handling this event on his own. event.preventDefault(); const nextSlateValue = convertRichTextElementsToEditorValue( - duplicateConfig(selectedRichTextComponentConfig, editorContext) - .elements[contextParams.locale] + duplicateConfig(selectedRichTextComponentConfig, { definitions }) + .elements[locale] ); const temporaryEditor = createTemporaryEditor(editor); @@ -713,18 +738,25 @@ function RichTextEditor(props: RichTextProps) { temporaryEditor.children as Array ); - actions.runChange(() => { - form.change(richTextElementsConfigPath, nextElements); - - const nextFocusedFields = getFocusedFieldsFromSlateSelection( - temporaryEditor, - path, - contextParams.locale - ); + const newFocusedFields = getFocusedFieldsFromSlateSelection( + temporaryEditor, + path, + locale + ); - return nextFocusedFields; - }); + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: richTextElementsConfigPath, + value: nextElements, + focussedField: newFocusedFields, + }, + }, + "*" + ); + previousCompiledRichText.current = props; lastChangeReason.current = "paste"; } else if ( // Slate only handles pasting if the clipboardData contains text/plain type. @@ -740,7 +772,7 @@ function RichTextEditor(props: RichTextProps) { const contentEditableClassName = useMemo(() => { const responsiveAlignmentStyles = mapResponsiveAlignmentToStyles(align, { - devices: editorContext.devices, + devices, resop, }); @@ -842,6 +874,16 @@ function RichTextEditor(props: RichTextProps) { ); } }} + onKeyDown={(event) => { + if ( + (event.metaKey && event.key === "z") || + (event.metaKey && event.shiftKey && event.key === "z") || + (event.ctrlKey && event.key === "z") || + (event.ctrlKey && event.key === "y") + ) { + lastChangeReason.current = "external"; + } + }} readOnly={!isEnabled} /> @@ -862,10 +904,6 @@ function isEditorValueEmpty(editorValue: Array) { ); } -function isConfigEqual(newConfig: any, oldConfig: any) { - return deepCompare(newConfig, oldConfig); -} - function mapResponsiveAlignmentToStyles( align: ResponsiveValue, { devices, resop }: { devices: Devices; resop: any } diff --git a/packages/core/src/compiler/builtins/$richText/utils/createTemporaryEditor.ts b/packages/core/src/compiler/builtins/$richText/utils/createTemporaryEditor.ts index 483913fc..abf4faf1 100644 --- a/packages/core/src/compiler/builtins/$richText/utils/createTemporaryEditor.ts +++ b/packages/core/src/compiler/builtins/$richText/utils/createTemporaryEditor.ts @@ -1,6 +1,7 @@ import { createEditor, Editor } from "slate"; import { withReact } from "slate-react"; import { withEasyblocks } from "../withEasyblocks"; +import { deepClone } from "@easyblocks/utils"; // Slate's transforms methods mutates given editor instance. // By creating temporary editor instance we can apply all transformations without @@ -9,8 +10,10 @@ function createTemporaryEditor( editor: Pick ): Editor { const temporaryEditor = withEasyblocks(withReact(createEditor())); - temporaryEditor.children = [...editor.children]; - temporaryEditor.selection = editor.selection ? { ...editor.selection } : null; + temporaryEditor.children = deepClone(editor.children); + temporaryEditor.selection = editor.selection + ? deepClone(editor.selection) + : null; return temporaryEditor; } diff --git a/packages/core/src/compiler/builtins/$richText/utils/getEditorSelectionFromFocusedFields.ts b/packages/core/src/compiler/builtins/$richText/utils/getEditorSelectionFromFocusedFields.ts index 102f0a0e..8d732cb1 100644 --- a/packages/core/src/compiler/builtins/$richText/utils/getEditorSelectionFromFocusedFields.ts +++ b/packages/core/src/compiler/builtins/$richText/utils/getEditorSelectionFromFocusedFields.ts @@ -4,7 +4,7 @@ import { parseFocusedRichTextPartConfigPath } from "./parseRichTextPartConfigPat function getEditorSelectionFromFocusedFields( focusedFields: Array, - form: any + formValues: any ): Selection { try { const anchorFocusedField = focusedFields[0]; @@ -26,13 +26,11 @@ function getEditorSelectionFromFocusedFields( focus: { offset: parsedFocusedField.range ? parsedFocusedField.range[1] - : dotNotationGet(form.values, focusFocusedField).value.length, + : dotNotationGet(formValues, focusFocusedField).value.length, path: parsedFocusedField.path, }, }; } catch (error) { - console.log(error); - return null; } } diff --git a/packages/core/src/compiler/builtins/$richText/utils/getRichTextComponentConfigFragment.ts b/packages/core/src/compiler/builtins/$richText/utils/getRichTextComponentConfigFragment.ts index 06f9dcd9..fe9f32d5 100644 --- a/packages/core/src/compiler/builtins/$richText/utils/getRichTextComponentConfigFragment.ts +++ b/packages/core/src/compiler/builtins/$richText/utils/getRichTextComponentConfigFragment.ts @@ -5,40 +5,41 @@ import { import { RichTextComponentConfig } from "../$richText"; import { RichTextPartComponentConfig } from "../$richTextPart/$richTextPart"; import { duplicateConfig } from "../../../duplicateConfig"; -import { EditorContextType } from "../../../types"; import { parseFocusedRichTextPartConfigPath } from "./parseRichTextPartConfigPath"; import { stripRichTextPartSelection } from "./stripRichTextTextPartSelection"; +import { EasyblocksCanvasState } from "../../../../components/EasyblocksCanvasProvider"; function getRichTextComponentConfigFragment( sourceRichTextComponentConfig: RichTextComponentConfig, - editorContext: EditorContextType + focussedField: EasyblocksCanvasState["focussedField"], + locale: EasyblocksCanvasState["locale"], + formValues: EasyblocksCanvasState["formValues"], + definitions: EasyblocksCanvasState["definitions"] ): RichTextComponentConfig & { _itemProps?: Record; } { - const { focussedField, form, contextParams } = editorContext; - const newRichTextComponentConfig: RichTextComponentConfig = { ...sourceRichTextComponentConfig, elements: { - [contextParams.locale]: [], + [locale]: [], }, }; focussedField.forEach((focusedField) => { const textPartConfig: RichTextPartComponentConfig = get( - form.values, + formValues, stripRichTextPartSelection(focusedField) ); const { path, range } = parseFocusedRichTextPartConfigPath(focusedField); - const newTextPartConfig = duplicateConfig(textPartConfig, editorContext); + const newTextPartConfig = duplicateConfig(textPartConfig, { definitions }); if (range) { newTextPartConfig.value = textPartConfig.value.slice(...range); } - let lastParentConfigPath = `elements.${contextParams.locale}`; + let lastParentConfigPath = `elements.${locale}`; path.slice(0, -1).forEach((pathIndex, index) => { let currentConfigPath = lastParentConfigPath; diff --git a/packages/core/src/compiler/builtins/$text/$text.editor.tsx b/packages/core/src/compiler/builtins/$text/$text.editor.tsx index 7a588a8c..514ed3b8 100644 --- a/packages/core/src/compiler/builtins/$text/$text.editor.tsx +++ b/packages/core/src/compiler/builtins/$text/$text.editor.tsx @@ -3,6 +3,7 @@ import { dotNotationGet } from "@easyblocks/utils"; import React, { ReactElement } from "react"; import { InternalNoCodeComponentProps } from "../../../components/ComponentBuilder/ComponentBuilder"; import { InlineTextarea } from "./InlineTextarea"; +import { useEasyblocksCanvasContext } from "../../../_internals"; type TextProps = { value: string | undefined; @@ -16,9 +17,15 @@ function TextEditor(props: TextProps) { __easyblocks: { path, runtime }, } = props; - const { form } = (window.parent as any).editorWindowAPI.editorContext; + const canvasContext = useEasyblocksCanvasContext(); + + if (!canvasContext) { + return null; + } + const { formValues } = canvasContext; + const valuePath = `${path}.value`; - const configValue = dotNotationGet(form.values, valuePath); + const configValue = dotNotationGet(formValues, valuePath); const isLocalTextReference = configValue.id?.startsWith("local."); return ( diff --git a/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx b/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx index 965dde1d..d008cef6 100644 --- a/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx +++ b/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx @@ -3,6 +3,7 @@ import React, { ElementRef, useRef, useState } from "react"; import { flushSync } from "react-dom"; import TextareaAutosize from "react-textarea-autosize"; import { useTextValue } from "../useTextValue"; +import { useEasyblocksCanvasContext } from "../../../_internals"; interface InlineTextProps { path: string; @@ -18,18 +19,30 @@ export function InlineTextarea({ const [isEnabled, setIsEnabled] = useState(false); const textAreaRef = useRef>(null); - const { - form, - contextParams: { locale }, - locales, - } = (window.parent as any).editorWindowAPI.editorContext; + const canvasContext = useEasyblocksCanvasContext(); + + if (!canvasContext) { + return null; + } + + const { formValues, locale, locales } = canvasContext; + const valuePath = `${path}.value`; - const value = dotNotationGet(form.values, valuePath); + const value = dotNotationGet(formValues, valuePath); const inputProps = useTextValue( value, (val: string | null) => { - form.change(valuePath, val); + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: valuePath, + value: val, + }, + }, + "*" + ); }, locale, locales, diff --git a/packages/core/src/compiler/duplicateConfig.ts b/packages/core/src/compiler/duplicateConfig.ts index f2bc2f6b..d4192b6d 100644 --- a/packages/core/src/compiler/duplicateConfig.ts +++ b/packages/core/src/compiler/duplicateConfig.ts @@ -2,11 +2,11 @@ import { deepClone, uniqueId } from "@easyblocks/utils"; import { NoCodeComponentEntry } from "../types"; import { configTraverse } from "./configTraverse"; import { traverseComponents } from "./traverseComponents"; -import { CompilationContextType } from "./types"; +import { AnyContextWithDefinitions } from "../types"; export function duplicateConfig< ConfigType extends NoCodeComponentEntry = NoCodeComponentEntry ->(inputConfig: ConfigType, compilationContext: CompilationContextType) { +>(inputConfig: ConfigType, compilationContext: AnyContextWithDefinitions) { // deep copy first const config = deepClone(inputConfig); diff --git a/packages/core/src/compiler/findComponentDefinition.ts b/packages/core/src/compiler/findComponentDefinition.ts index de47b9cf..9498cdae 100644 --- a/packages/core/src/compiler/findComponentDefinition.ts +++ b/packages/core/src/compiler/findComponentDefinition.ts @@ -1,11 +1,6 @@ import { toArray } from "@easyblocks/utils"; -import { NoCodeComponentEntry } from "../types"; -import { - InternalComponentDefinition, - InternalComponentDefinitions, -} from "./types"; - -type AnyContextWithDefinitions = { definitions: InternalComponentDefinitions }; +import { NoCodeComponentEntry, AnyContextWithDefinitions } from "../types"; +import { InternalComponentDefinition } from "./types"; function allDefs( context?: AnyContextWithDefinitions diff --git a/packages/core/src/compiler/traverseComponents.ts b/packages/core/src/compiler/traverseComponents.ts index 0d0f426d..77a84d7e 100644 --- a/packages/core/src/compiler/traverseComponents.ts +++ b/packages/core/src/compiler/traverseComponents.ts @@ -1,7 +1,7 @@ import { NoCodeComponentEntry } from "../types"; import { findComponentDefinition } from "./findComponentDefinition"; import { isSchemaPropComponent } from "./schema"; -import { CompilationContextType } from "./types"; +import { AnyContextWithDefinitions } from "../types"; type TraverseComponentsCallback = (arg: { path: string; @@ -12,7 +12,7 @@ type TraverseComponentsCallback = (arg: { */ function traverseComponents( config: NoCodeComponentEntry, - context: CompilationContextType, + context: AnyContextWithDefinitions, callback: TraverseComponentsCallback ): void { traverseComponentsInternal(config, context, callback, ""); @@ -20,7 +20,7 @@ function traverseComponents( function traverseComponentsArray( array: NoCodeComponentEntry[], - context: CompilationContextType, + context: AnyContextWithDefinitions, callback: TraverseComponentsCallback, path: string ) { @@ -31,7 +31,7 @@ function traverseComponentsArray( function traverseComponentsInternal( componentConfig: NoCodeComponentEntry, - context: CompilationContextType, + context: AnyContextWithDefinitions, callback: TraverseComponentsCallback, path: string ) { diff --git a/packages/core/src/compiler/types.ts b/packages/core/src/compiler/types.ts index eb570050..0b7cb128 100644 --- a/packages/core/src/compiler/types.ts +++ b/packages/core/src/compiler/types.ts @@ -6,6 +6,7 @@ import { ContextParams, Devices, EditingInfoBase, + EditorActions, ExternalSchemaProp, ExternalTypeDefinition, FieldPortal, @@ -134,31 +135,6 @@ export type InternalComponentDefinitions = { components: InternalRenderableComponentDefinition[]; }; -type EditorActions = { - notify: (message: string) => void; - openComponentPicker: (config: { - path: string; - componentTypes?: string[]; - }) => Promise; - moveItems: ( - fieldNames: Array, - direction: "top" | "right" | "bottom" | "left" - ) => void; - replaceItems: (paths: Array, newConfig: NoCodeComponentEntry) => void; - removeItems: (fieldNames: Array) => void; - insertItem: (insertItemProps: { - name: string; - index: number; - block: NoCodeComponentEntry; - }) => void; - duplicateItems: (fieldNames: Array) => void; - pasteItems: (items: Array) => void; - runChange: Array | void>( - configChangeCallback: Callback - ) => void; - logSelectedItems: () => void; -}; - export type EditorContextType = CompilationContextType & { breakpointIndex: string; locales: Locale[]; diff --git a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx index e81f8232..672dedaa 100644 --- a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx +++ b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx @@ -190,7 +190,7 @@ function getCompiledSubcomponents( path: string, meta: CompilationMetadata, isEditing: boolean, - components: ComponentBuilderProps["components"] + components: Record> ) { const originalPath = path; @@ -273,7 +273,8 @@ function getCompiledSubcomponents( name: path, index: 0, block: event.data.payload.config, - }) + }), + "*" ); } } @@ -281,7 +282,7 @@ function getCompiledSubcomponents( window.addEventListener("message", handleComponentPickerCloseMessage); - window.parent.postMessage(componentPickerOpened(originalPath)); + window.parent.postMessage(componentPickerOpened(originalPath), "*"); }} meta={meta} />, @@ -300,16 +301,7 @@ export type ComponentBuilderProps = { [key: string]: any; // any extra props passed in components }; compiled: CompiledComponentConfig; - components: { - "@easyblocks/missing-component": ComponentType; - "@easyblocks/rich-text.client": ComponentType; - "@easyblocks/rich-text-block-element": ComponentType; - "@easyblocks/rich-text-line-element": ComponentType; - "@easyblocks/rich-text-part": ComponentType; - "@easyblocks/text.client": ComponentType; - "EditableComponentBuilder.client": ComponentType; - [key: string]: ComponentType; - }; + components: Record>; }; export type InternalNoCodeComponentProps = NoCodeComponentProps & { @@ -459,7 +451,7 @@ function ComponentBuilder(props: ComponentBuilderProps): ReactElement | null { __easyblocks: easyblocksProp, }; - return ; + return ; } function getComponent( diff --git a/packages/core/src/components/EasyblocksCanvasProvider.tsx b/packages/core/src/components/EasyblocksCanvasProvider.tsx new file mode 100644 index 00000000..5ee5ed62 --- /dev/null +++ b/packages/core/src/components/EasyblocksCanvasProvider.tsx @@ -0,0 +1,69 @@ +import React, { + createContext, + ReactNode, + useContext, + useState, + useEffect, + ComponentType, +} from "react"; +import { EditorContextType } from "../_internals"; +import { + CompilationMetadata, + CompiledShopstoryComponentConfig, + FetchOutputResources, +} from "../types"; + +export type EasyblocksCanvasState = { + meta: CompilationMetadata; + compiled: CompiledShopstoryComponentConfig; + externalData: FetchOutputResources; + formValues: EditorContextType["form"]["values"]; + definitions: EditorContextType["definitions"]; + locale: EditorContextType["contextParams"]["locale"]; + locales: EditorContextType["locales"]; + isEditing: EditorContextType["isEditing"]; + devices: EditorContextType["devices"]; + focussedField: EditorContextType["focussedField"]; + components: Record>; +}; + +const EasyblocksCanvasContext = createContext( + null +); + +type EasyblocksCanvasProviderProps = { + children: ReactNode; + components: Record>; +}; + +const EasyblocksCanvasProvider: React.FC = ({ + children, + components, +}) => { + const [state, setState] = useState(null); + + useEffect(() => { + const handler = (event: any) => { + if (event.data.type === "@easyblocks/canvas-data") { + const data = JSON.parse(event.data.data); + setState((prevState) => ({ ...prevState, ...data, components })); + } + }; + window.addEventListener("message", handler); + return () => { + window.removeEventListener("message", handler); + }; + }, []); + + return ( + + {children} + + ); +}; + +const useEasyblocksCanvasContext = () => { + return useContext(EasyblocksCanvasContext); +}; + +export { EasyblocksCanvasProvider, useEasyblocksCanvasContext }; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index e52d6936..a2e62cd2 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -1,5 +1,5 @@ import { serialize } from "@easyblocks/utils"; -import { NoCodeComponentEntry, SchemaProp } from "./types"; +import { ConfigDevices, NoCodeComponentEntry, SchemaProp } from "./types"; import { Component$$$SchemaProp } from "./compiler/schema"; type EasyblocksEditorEventData< @@ -119,6 +119,102 @@ type ItemMovedEvent = MessageEvent< > >; +type ChangeResponsiveEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/change-responsive", + { + device: keyof ConfigDevices; + } + > +>; + +type UndoEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/undo", + { + type: "@easyblocks-editor/undo"; + } + > +>; + +type CanvasLoadedEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/canvas-loaded", + { + type: "@easyblocks-editor/canvas-loaded"; + } + > +>; + +type RemoveItemsEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/remove-items", + { + type: "@easyblocks-editor/remove-items"; + paths: Array; + } + > +>; + +type PasteItemsEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/paste-items", + { + type: "@easyblocks-editor/paste-items"; + configs: any; + } + > +>; + +type MoveItemsEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/move-items", + { + type: "@easyblocks-editor/move-items"; + paths: Array; + direction: "top" | "right" | "bottom" | "left"; + } + > +>; + +type LogSelectedEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/log-selected-items", + { + type: "@easyblocks-editor/log-selected-items"; + } + > +>; + +type FormChangeEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/form-change", + { + key: string; + value: any; + focussedField?: Array | string; + } + > +>; + +type RedoEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/redo", + { + type: "@easyblocks-editor/redo"; + } + > +>; + +type SetFocussedFieldEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/focus", + { + target: Array | string; + } + > +>; + function itemMoved( payload: InferShopstoryEditorEventData["payload"] ): InferShopstoryEditorEventData { @@ -145,4 +241,14 @@ export type { RichTextChangedEvent, SelectionFramePositionChangedEvent, EasyblocksEditorEventData, + ChangeResponsiveEvent, + UndoEvent, + RedoEvent, + SetFocussedFieldEvent, + FormChangeEvent, + CanvasLoadedEvent, + RemoveItemsEvent, + PasteItemsEvent, + MoveItemsEvent, + LogSelectedEvent, }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bdc43b1a..486011ad 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,7 @@ import { ComponentType, ReactElement } from "react"; import { PartialDeep } from "type-fest"; import { Locale } from "./locales"; +import { InternalComponentDefinitions } from "./compiler/types"; export type ScalarOrCollection = T | Array; @@ -997,3 +998,32 @@ export type TokenValue = { tokenId?: string; widgetId?: string; }; + +export type AnyContextWithDefinitions = { + definitions: InternalComponentDefinitions; +}; + +export type EditorActions = { + notify: (message: string) => void; + openComponentPicker: (config: { + path: string; + componentTypes?: string[]; + }) => Promise; + moveItems: ( + fieldNames: Array, + direction: "top" | "right" | "bottom" | "left" + ) => void; + replaceItems: (paths: Array, newConfig: NoCodeComponentEntry) => void; + removeItems: (fieldNames: Array) => void; + insertItem: (insertItemProps: { + name: string; + index: number; + block: NoCodeComponentEntry; + }) => void; + duplicateItems: (fieldNames: Array) => void; + pasteItems: (items: Array) => void; + runChange: Array | void>( + configChangeCallback: Callback + ) => void; + logSelectedItems: () => void; +}; diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 2d672e3b..1853aec9 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -21,7 +21,6 @@ "@radix-ui/react-toggle-group": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", "lodash": "^4.17.21", - "react": "^18.2.0", "react-hot-toast": "^2.4.0", "react-modal": "^3.16.1", "react-select": "^5.7.0", diff --git a/packages/editor/src/CanvasRoot/CanvasRoot.tsx b/packages/editor/src/CanvasRoot/CanvasRoot.tsx index fc0f0b83..a5c5dde4 100644 --- a/packages/editor/src/CanvasRoot/CanvasRoot.tsx +++ b/packages/editor/src/CanvasRoot/CanvasRoot.tsx @@ -1,24 +1,39 @@ import React, { ReactNode } from "react"; -import { useEditorGlobalKeyboardShortcuts } from "../useEditorGlobalKeyboardShortcuts"; +import { useEasyblocksCanvasContext } from "@easyblocks/core/_internals"; +import { useCanvasGlobalKeyboardShortcuts } from "../useCanvasGlobalKeyboardShortcuts"; type CanvasRootProps = { children: ReactNode; }; function CanvasRoot(props: CanvasRootProps) { - const { editorContext } = window.parent.editorWindowAPI; + const canvasContext = useEasyblocksCanvasContext(); - useEditorGlobalKeyboardShortcuts(editorContext); + if (!canvasContext) { + return null; + } + + const { isEditing } = canvasContext; + + useCanvasGlobalKeyboardShortcuts(); return (
{ - if (editorContext.isEditing) { - editorContext.setFocussedField([]); + if (isEditing) { + window.parent.postMessage( + { + type: "@easyblocks-editor/focus", + payload: { + target: [], + }, + }, + "*" + ); } }} > - {editorContext.isEditing && ( + {isEditing && (