diff --git a/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx b/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx index b221e626..a7fbd29b 100644 --- a/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx +++ b/packages/core/src/compiler/builtins/$richText/$richText.editor.tsx @@ -85,12 +85,15 @@ function RichTextEditor(props: RichTextProps) { // } = editorContext; const setFocussedField = (field: Array | string) => { - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: field, + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: field, + }, }, - }); + "*" + ); }; const { @@ -263,13 +266,16 @@ function RichTextEditor(props: RichTextProps) { ); // form.change(path, nextRichTextElement); - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: path, - value: nextRichTextElement, + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: nextRichTextElement, + }, }, - }); + "*" + ); } } }, @@ -349,14 +355,17 @@ function RichTextEditor(props: RichTextProps) { getAbsoluteRichTextPartPath(focusedRichTextPart, path, locale) ); - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: path, - value: newRichTextElement, - focussedField: newFocusedFields, + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: newRichTextElement, + focussedField: newFocusedFields, + }, }, - }); + "*" + ); } } @@ -544,22 +553,25 @@ function RichTextEditor(props: RichTextProps) { previousRichTextComponentConfig.current = newRichTextElement; - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: path, - value: newRichTextElement, - ...(editor.selection - ? { - focussedField: getFocusedFieldsFromSlateSelection( - editor, - path, - 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), [isConfigChanged, locale] ); @@ -661,13 +673,16 @@ function RichTextEditor(props: RichTextProps) { // form.change(path, nextRichTextComponentConfig); - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: path, - value: 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 @@ -679,13 +694,16 @@ function RichTextEditor(props: RichTextProps) { editor.children as Array ); // form.change(path, nextRichTextComponentConfig); - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: path, - value: nextRichTextComponentConfig, + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: path, + value: nextRichTextComponentConfig, + }, }, - }); + "*" + ); } previousRichTextComponentConfig.current = nextRichTextComponentConfig; @@ -801,18 +819,21 @@ function RichTextEditor(props: RichTextProps) { // return nextFocusedFields; // }); - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: richTextElementsConfigPath, - value: nextElements, - focussedField: getFocusedFieldsFromSlateSelection( - temporaryEditor, - path, - locale - ), + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: richTextElementsConfigPath, + value: nextElements, + focussedField: getFocusedFieldsFromSlateSelection( + temporaryEditor, + path, + locale + ), + }, }, - }); + "*" + ); lastChangeReason.current = "paste"; } else if ( diff --git a/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx b/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx index bfffe417..f2a09631 100644 --- a/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx +++ b/packages/core/src/compiler/builtins/$text/InlineTextarea.tsx @@ -26,13 +26,16 @@ export function InlineTextarea({ const inputProps = useTextValue( value, (val: string | null) => { - window.parent.postMessage({ - type: "@easyblocks-editor/form-change", - payload: { - key: valuePath, - value: val, + window.parent.postMessage( + { + type: "@easyblocks-editor/form-change", + payload: { + key: valuePath, + value: val, + }, }, - }); + "*" + ); }, locale, locales, diff --git a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx index e81f8232..ac75543c 100644 --- a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx +++ b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx @@ -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} />, diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index fc856cac..162faac3 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -137,6 +137,15 @@ type UndoEvent = MessageEvent< > >; +type CanvasLoadedEvent = MessageEvent< + EasyblocksEditorEventData< + "@easyblocks-editor/canvas-loaded", + { + type: "@easyblocks-editor/canvas-loaded"; + } + > +>; + type FormChangeEvent = MessageEvent< EasyblocksEditorEventData< "@easyblocks-editor/form-change", @@ -206,4 +215,5 @@ export type { RedoEvent, SetFocussedFieldEvent, FormChangeEvent, + CanvasLoadedEvent, }; diff --git a/packages/editor/src/CanvasRoot/CanvasRoot.tsx b/packages/editor/src/CanvasRoot/CanvasRoot.tsx index 8a49f589..6c86e7e3 100644 --- a/packages/editor/src/CanvasRoot/CanvasRoot.tsx +++ b/packages/editor/src/CanvasRoot/CanvasRoot.tsx @@ -1,5 +1,6 @@ import React, { ReactNode } from "react"; import { useEasyblocksCanvasContext } from "@easyblocks/core/_internals"; +import { useCanvasGlobalKeyboardShortcuts } from "../useCanvasGlobalKeyboardShortcuts"; type CanvasRootProps = { children: ReactNode; @@ -8,16 +9,21 @@ type CanvasRootProps = { function CanvasRoot(props: CanvasRootProps) { const { isEditing } = useEasyblocksCanvasContext(); + useCanvasGlobalKeyboardShortcuts(); + return (
{ if (isEditing) { - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: [], + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: [], + }, }, - }); + "*" + ); } }} > diff --git a/packages/editor/src/EditableComponentBuilder/BlockControls.tsx b/packages/editor/src/EditableComponentBuilder/BlockControls.tsx index e2476a89..fd0acc56 100644 --- a/packages/editor/src/EditableComponentBuilder/BlockControls.tsx +++ b/packages/editor/src/EditableComponentBuilder/BlockControls.tsx @@ -195,12 +195,15 @@ export function BlocksControls({ const nextFocusedField = getNextFocusedField(); - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: nextFocusedField, + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: nextFocusedField, + }, }, - }); + "*" + ); if (isMultipleSelection) { document.getSelection()?.removeAllRanges(); diff --git a/packages/editor/src/EditableComponentBuilder/SelectionFrameController.tsx b/packages/editor/src/EditableComponentBuilder/SelectionFrameController.tsx index bdea7bf9..894aef34 100644 --- a/packages/editor/src/EditableComponentBuilder/SelectionFrameController.tsx +++ b/packages/editor/src/EditableComponentBuilder/SelectionFrameController.tsx @@ -125,35 +125,19 @@ function SelectionFrameController({ !window.document.contains(node) && path === focussedField[0] ) { - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: [], + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: [], + }, }, - }); + "*" + ); } }; }); - React.useEffect(() => { - const onWindowMessage = (event: MessageEvent) => { - if (event.data.type === "@easyblocks-editor/focus-field") { - if ( - event.data.payload.target === path && - event.data.payload.scrollIntoView - ) { - node?.scrollIntoView({ - behavior: "smooth", - }); - } - } - }; - window.parent.addEventListener("message", onWindowMessage); - return () => { - window.parent.removeEventListener("message", onWindowMessage); - }; - }); - return (
{ if (isDisabled || !node) { return; @@ -196,11 +178,12 @@ function useUpdateFramePosition({ const updateSelectionFramePosition = createThrottledHandler(() => { const nodeRect = node.getBoundingClientRect(); - dispatch( + window.parent.postMessage( selectionFramePositionChanged( nodeRect, window.document.documentElement.getBoundingClientRect() - ) + ), + "*" ); }); @@ -210,7 +193,7 @@ function useUpdateFramePosition({ const handleResize = createThrottledHandler(() => { const nodeRect = node.getBoundingClientRect(); - dispatch(selectionFramePositionChanged(nodeRect)); + window.parent.postMessage(selectionFramePositionChanged(nodeRect), "*"); }); window.addEventListener("resize", handleResize, { @@ -224,7 +207,10 @@ function useUpdateFramePosition({ event.target as HTMLElement ).getBoundingClientRect(); - dispatch(selectionFramePositionChanged(nodeRect, containerRect)); + window.parent.postMessage( + selectionFramePositionChanged(nodeRect, containerRect), + "*" + ); }); const closestScrollableElement = node.closest( @@ -239,11 +225,12 @@ function useUpdateFramePosition({ } ); - dispatch( + window.parent.postMessage( selectionFramePositionChanged( node.getBoundingClientRect(), closestScrollableElement?.getBoundingClientRect() - ) + ), + "*" ); return () => { diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 8aae9b24..687fe121 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -24,6 +24,7 @@ import { validate, } from "@easyblocks/core"; import { + CanvasLoadedEvent, ChangeResponsiveEvent, CompilationContextType, ComponentPickerOpenedEvent, @@ -872,7 +873,10 @@ const EditorContent = ({ }); }, []); + const [canvasLoaded, setCanvasLoaded] = useState(false); + useEffect(() => { + sendCanvasData(); if (window.editorWindowAPI.onUpdate) { window.editorWindowAPI.onUpdate(); } @@ -882,6 +886,7 @@ const EditorContent = ({ isEditing, currentViewport, externalData, + canvasLoaded, ]); useEffect(() => { @@ -895,7 +900,31 @@ const EditorContent = ({ | UndoEvent | RedoEvent | FormChangeEvent + | CanvasLoadedEvent ) { + if (event.data.type === "@easyblocks-editor/remove-items") { + actions.removeItems(event.data.payload.paths); + } + + if (event.data.type === "@easyblocks-editor/paste-items") { + actions.pasteItems(event.data.payload.configs); + } + + if (event.data.type === "@easyblocks-editor/move-items") { + actions.moveItems( + event.data.payload.paths, + event.data.payload.direction + ); + } + + if (event.data.type === "@easyblocks-editor/log-selected-items") { + actions.logSelectedItems(); + } + + if (event.data.type === "@easyblocks-editor/canvas-loaded") { + setCanvasLoaded(true); + } + if (event.data.type === "@easyblocks-editor/form-change") { if (event.data.payload.focussedField) { actions.runChange(() => { @@ -1023,18 +1052,23 @@ const EditorContent = ({ return () => window.removeEventListener("message", handleEditorEvents); }, []); - const shopstoryCanvasIframe = window.document.getElementById( - "shopstory-canvas" - ) as HTMLIFrameElement | undefined; + const shopstoryCanvasIframe = useRef(null); + + useEffect(() => { + const iframe = document.getElementById( + "shopstory-canvas" + ) as HTMLIFrameElement | null; + shopstoryCanvasIframe.current = iframe; + }, []); const sendCanvasData = () => { - shopstoryCanvasIframe?.contentWindow?.postMessage( + shopstoryCanvasIframe?.current?.contentWindow?.postMessage( { type: "@easyblocks/canvas-data", data: JSON.stringify({ compiled: renderableContent, meta, - externalData, + externalData: externalData, formValues: form.values, definitions: editorContext.definitions, locale: editorContext.contextParams.locale, @@ -1050,7 +1084,7 @@ const EditorContent = ({ const [isDataSaverOverlayOpen, setDataSaverOverlayOpen] = useState(false); - useEditorGlobalKeyboardShortcuts(editorContext, shopstoryCanvasIframe); + useEditorGlobalKeyboardShortcuts(editorContext); const { saveNow } = useDataSaver(initialDocument, editorContext); @@ -1123,11 +1157,6 @@ const EditorContent = ({ height={iframeSize.height} transform={iframeSize.transform} containerRef={iframeContainerRef} - onIframeLoaded={() => { - setTimeout(() => { - sendCanvasData(); - }, 10); - }} /> {isEditing && ( { + window.parent.postMessage( + { + type: "@easyblocks-editor/canvas-loaded", + }, + "*" + ); + }, []); + const { meta, compiled, externalData, formValues, definitions } = useEasyblocksCanvasContext(); @@ -94,12 +103,15 @@ export function EasyblocksCanvas({ activeDraggedEntryPath.current = dragDataSchema.parse( event.active.data.current ).path; - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: [], + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: [], + }, }, - }); + "*" + ); }} onDragEnd={(event) => { document.documentElement.style.cursor = ""; @@ -110,12 +122,15 @@ export function EasyblocksCanvas({ if (event.over.id === event.active.id) { // If the dragged item is dropped on itself, we want to refocus the dragged item. - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: activeData.path, + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: activeData.path, + }, }, - }); + "*" + ); } else { const itemMovedEvent = itemMoved({ fromPath: activeData.path, @@ -126,28 +141,34 @@ export function EasyblocksCanvas({ }); requestAnimationFrame(() => { - window.parent.postMessage(itemMovedEvent); + window.parent.postMessage(itemMovedEvent, "*"); }); } } else { // If there was no drop target, we want to refocus the dragged item. - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: activeData.path, + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: activeData.path, + }, }, - }); + "*" + ); } }} onDragCancel={(event) => { document.documentElement.style.cursor = ""; // If the drag was canceled, we want to refocus dragged item. - window.parent.postMessage({ - type: "@easyblocks-editor/focus-field", - payload: { - target: dragDataSchema.parse(event.active.data.current).path, + window.parent.postMessage( + { + type: "@easyblocks-editor/focus-field", + payload: { + target: dragDataSchema.parse(event.active.data.current).path, + }, }, - }); + "*" + ); }} > diff --git a/packages/editor/src/useCanvasGlobalKeyboardShortcuts.ts b/packages/editor/src/useCanvasGlobalKeyboardShortcuts.ts new file mode 100644 index 00000000..c1317506 --- /dev/null +++ b/packages/editor/src/useCanvasGlobalKeyboardShortcuts.ts @@ -0,0 +1,209 @@ +import { + EditorContextType, + duplicateConfig, + useEasyblocksCanvasContext, +} from "@easyblocks/core/_internals"; +import { dotNotationGet, preOrderPathComparator } from "@easyblocks/utils"; +import { useEffect } from "react"; +import { AnyContextWithDefinitions } from "../../core/dist/types/compiler/types"; + +const GLOBAL_SHORTCUTS_KEYS = [ + "Delete", + "Backspace", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "l", + "L", +]; + +const DATA_TRANSFER_FORMAT = "text/x-shopstory"; + +const actions = { + removeItems: (paths: string[]) => { + window.parent.postMessage( + { + type: "@easyblocks-editor/remove-items", + payload: { + paths, + }, + }, + "*" + ); + }, + moveItems: (paths: string[], direction: "top" | "bottom") => { + window.parent.postMessage( + { + type: "@easyblocks-editor/move-items", + payload: { + paths, + direction, + }, + }, + "*" + ); + }, + logSelectedItems: () => { + window.parent.postMessage( + { + type: "@easyblocks-editor/log-selected-items", + payload: {}, + }, + "*" + ); + }, + pasteItems: (configs: any[]) => { + window.parent.postMessage( + { + type: "@easyblocks-editor/paste-items", + payload: { + configs, + }, + }, + "*" + ); + }, +}; + +function useCanvasGlobalKeyboardShortcuts() { + const { formValues, definitions, focussedField } = + useEasyblocksCanvasContext(); + + useEffect(() => { + function handleKeydown(event: KeyboardEvent): void { + if (isTargetInputElement(event.target)) { + return; + } + + if (!isGlobalShortcut(event) || !isAnyFieldSelected(focussedField)) { + return; + } + + if (event.key === "Delete" || event.key === "Backspace") { + actions.removeItems(focussedField); + } else if (event.key === "ArrowUp" || event.key === "ArrowLeft") { + actions.moveItems(focussedField, "top"); + } else if (event.key === "ArrowDown" || event.key === "ArrowRight") { + actions.moveItems(focussedField, "bottom"); + } else if (event.key.toUpperCase() === "L") { + actions.logSelectedItems(); + } + } + + function handleCopy(event: ClipboardEvent): void { + if (!canHandleCopyPaste(focussedField, event)) { + return; + } + const configs = getConfigsToCopy(focussedField, formValues, definitions); + + event.preventDefault(); + event.clipboardData?.setData( + DATA_TRANSFER_FORMAT, + JSON.stringify(configs) + ); + } + + function handleCut(event: ClipboardEvent): void { + if (!canHandleCopyPaste(focussedField, event)) { + return; + } + + const configs = getConfigsToCopy(focussedField, formValues, definitions); + + event.preventDefault(); + event.clipboardData?.setData( + DATA_TRANSFER_FORMAT, + JSON.stringify(configs) + ); + + actions.removeItems(focussedField); + } + + function handlePaste(event: ClipboardEvent): void { + if (!canHandleCopyPaste(focussedField, event)) { + return; + } + + const rawData = event.clipboardData?.getData(DATA_TRANSFER_FORMAT); + + if (!rawData || rawData === "") { + return; + } + + try { + const parsedData = JSON.parse(rawData); + + const data = Array.isArray(parsedData) ? parsedData : [parsedData]; + + actions.pasteItems(data); + event.preventDefault(); + } catch (e) { + console.error(e); + return; + } + } + + window.document.addEventListener("keydown", handleKeydown); + window.document.addEventListener("copy", handleCopy); + window.document.addEventListener("cut", handleCut); + window.document.addEventListener("paste", handlePaste); + + return () => { + window.document.removeEventListener("keydown", handleKeydown); + window.document.removeEventListener("copy", handleCopy); + window.document.removeEventListener("cut", handleCut); + window.document.removeEventListener("paste", handlePaste); + }; + }); +} + +function isTargetInputElement(target: EventTarget | null): boolean { + return ( + isTargetHtmlElement(target) && + (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || + (target.tagName === "DIV" && target.getAttribute("role") === "textbox")) + ); +} + +function isTargetHtmlElement( + element: EventTarget | null +): element is HTMLElement { + return element !== null; +} + +function getConfigsToCopy( + paths: string[], + formValues: any, + definitions: AnyContextWithDefinitions +) { + const sortedPaths = [...paths].sort(preOrderPathComparator("ascending")); + return sortedPaths.map((path) => { + const config = dotNotationGet(formValues, path); + return duplicateConfig(config, definitions); + }); +} + +function canHandleCopyPaste(focussedField: string[], event: ClipboardEvent) { + const notInsideInputElement = !( + isTargetInputElement(event.target) || + isTargetInputElement(document.activeElement) + ); + + const insideEditorIFrame = window.self !== window.top; + const focussedFieldSelected = isAnyFieldSelected(focussedField); + return notInsideInputElement && insideEditorIFrame && focussedFieldSelected; +} + +function isGlobalShortcut(event: KeyboardEvent): boolean { + return GLOBAL_SHORTCUTS_KEYS.includes(event.key); +} + +// FIXME: This is my mistake, because I was lazy at the beginning and it was easier for me to introduce changes +// by assuming that non empty array with empty string means no fields selected. +// IMO this is stupid and can lead to confusion. +function isAnyFieldSelected(focussedField: string[]) { + return focussedField.length > 0 && focussedField[0] !== ""; +} + +export { useCanvasGlobalKeyboardShortcuts }; diff --git a/packages/editor/src/useEditorGlobalKeyboardShortcuts.ts b/packages/editor/src/useEditorGlobalKeyboardShortcuts.ts index 3fb54e6b..b59d70b6 100644 --- a/packages/editor/src/useEditorGlobalKeyboardShortcuts.ts +++ b/packages/editor/src/useEditorGlobalKeyboardShortcuts.ts @@ -18,10 +18,7 @@ const GLOBAL_SHORTCUTS_KEYS = [ const DATA_TRANSFER_FORMAT = "text/x-shopstory"; -function useEditorGlobalKeyboardShortcuts( - editorContext: EditorContextType, - canvas: HTMLIFrameElement | undefined -) { +function useEditorGlobalKeyboardShortcuts(editorContext: EditorContextType) { useEffect(() => { const { focussedField: focusedFields, actions } = editorContext; @@ -104,27 +101,11 @@ function useEditorGlobalKeyboardShortcuts( window.document.addEventListener("cut", handleCut); window.document.addEventListener("paste", handlePaste); - canvas?.contentWindow?.document?.addEventListener("keydown", handleKeydown); - canvas?.contentWindow?.document?.addEventListener("copy", handleCopy); - canvas?.contentWindow?.document?.addEventListener("cut", handleCut); - canvas?.contentWindow?.document?.addEventListener("paste", handlePaste); - return () => { window.document.removeEventListener("keydown", handleKeydown); window.document.removeEventListener("copy", handleCopy); window.document.removeEventListener("cut", handleCut); window.document.removeEventListener("paste", handlePaste); - - canvas?.contentWindow?.document?.removeEventListener( - "keydown", - handleKeydown - ); - canvas?.contentWindow?.document?.removeEventListener("copy", handleCopy); - canvas?.contentWindow?.document?.removeEventListener("cut", handleCut); - canvas?.contentWindow?.document?.removeEventListener( - "paste", - handlePaste - ); }; }); }