From 21befdd3c3f439c78ae4d5b2287f2cae7409ef13 Mon Sep 17 00:00:00 2001 From: Roman Pominov Date: Wed, 23 Nov 2022 14:25:03 +0400 Subject: [PATCH] use clipboard api --- apps/designer/app/canvas/canvas.tsx | 31 ++++- apps/designer/app/canvas/shared/instance.ts | 24 ++-- .../app/canvas/shared/use-shortcuts.ts | 25 ---- apps/designer/app/designer/designer.tsx | 33 ++++- apps/designer/app/shared/copy-paste.ts | 52 ------- apps/designer/app/shared/copy-paste/index.ts | 2 + .../app/shared/copy-paste/serialize.ts | 25 ++++ .../copy-paste/use-instance-copy-paste.ts | 130 ++++++++++++++++++ .../shared/props-utils/clone-props.test.ts | 20 --- .../app/shared/props-utils/clone-props.ts | 15 -- apps/designer/app/shared/props-utils/index.ts | 1 - packages/project/src/index.ts | 2 + .../src/shared/props/clone-user-props.ts | 5 + .../src/shared/props/create-instance-props.ts | 17 +++ packages/project/src/shared/props/index.ts | 2 + 15 files changed, 253 insertions(+), 131 deletions(-) delete mode 100644 apps/designer/app/shared/copy-paste.ts create mode 100644 apps/designer/app/shared/copy-paste/index.ts create mode 100644 apps/designer/app/shared/copy-paste/serialize.ts create mode 100644 apps/designer/app/shared/copy-paste/use-instance-copy-paste.ts delete mode 100644 apps/designer/app/shared/props-utils/clone-props.test.ts delete mode 100644 apps/designer/app/shared/props-utils/clone-props.ts create mode 100644 packages/project/src/shared/props/clone-user-props.ts create mode 100644 packages/project/src/shared/props/create-instance-props.ts create mode 100644 packages/project/src/shared/props/index.ts diff --git a/apps/designer/app/canvas/canvas.tsx b/apps/designer/app/canvas/canvas.tsx index 6e3d59022a..991413a46c 100644 --- a/apps/designer/app/canvas/canvas.tsx +++ b/apps/designer/app/canvas/canvas.tsx @@ -10,7 +10,7 @@ import { type OnChangeChildren, type Tree, } from "@webstudio-is/react-sdk"; -import { useSubscribe } from "~/shared/pubsub"; +import { publish, useSubscribe } from "~/shared/pubsub"; import { useShortcuts } from "./shared/use-shortcuts"; import { useDeleteInstance, @@ -47,6 +47,8 @@ import { useDragAndDrop } from "./shared/use-drag-drop"; import { utils } from "@webstudio-is/project"; import { useSubscribeDesignerReady } from "./shared/use-designer-ready"; import type { Asset } from "@webstudio-is/asset-uploader"; +import { useInstanceCopyPaste } from "~/shared/copy-paste"; +import { useSelectedInstance } from "./shared/nano-states"; registerContainers(); @@ -95,6 +97,29 @@ const useAssets = (initialAssets: Array) => { return assets; }; +const useCopyPaste = () => { + const [instance] = useSelectedInstance(); + const [allUserProps] = useAllUserProps(); + + const selectedInstanceData = useMemo( + () => instance && { instance, props: allUserProps[instance.id]?.props }, + [allUserProps, instance] + ); + + // We need to initialize this in both canvas and designer, + // because the events will fire in either one, depending on where the focus is + useInstanceCopyPaste({ + selectedInstanceData, + allowAnyTarget: true, + onCut: (instance) => { + publish({ type: "deleteInstance", payload: { id: instance.id } }); + }, + onPaste: (instance, props) => { + publish({ type: "insertInstance", payload: { instance, props } }); + }, + }); +}; + type DesignModeProps = { treeId: Tree["id"]; buildId: Build["id"]; @@ -105,7 +130,7 @@ const DesignMode = ({ treeId, buildId }: DesignModeProps) => { useManageDesignModeStyles(); useManageProps(); usePublishSelectedInstanceData(treeId); - useInsertInstance(); + useInsertInstance({ treeId }); useReparentInstance(); useDeleteInstance(); usePublishRootInstance(); @@ -120,6 +145,8 @@ const DesignMode = ({ treeId, buildId }: DesignModeProps) => { useSubscribeScrollState(); usePublishTextEditingInstanceId(); useDragAndDrop(); + useCopyPaste(); + return null; }; diff --git a/apps/designer/app/canvas/shared/instance.ts b/apps/designer/app/canvas/shared/instance.ts index 0c9bc3ec51..2804921807 100644 --- a/apps/designer/app/canvas/shared/instance.ts +++ b/apps/designer/app/canvas/shared/instance.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import ObjectId from "bson-objectid"; import { type Instance, - type InstanceProps, + type UserProp, type Tree, getComponentMeta, allUserPropsContainer, @@ -48,7 +48,7 @@ declare module "~/shared/pubsub" { insertInstance: { instance: Instance; dropTarget?: { parentId: Instance["id"]; position: number }; - props?: InstanceProps; + props?: Array; }; unselectInstance: undefined; } @@ -93,7 +93,7 @@ export const findInsertLocation = ( }; }; -export const useInsertInstance = () => { +export const useInsertInstance = ({ treeId }: { treeId: string }) => { const [selectedInstance, setSelectedInstance] = useSelectedInstance(); const [breakpoints] = useBreakpoints(); @@ -115,7 +115,11 @@ export const useInsertInstance = () => { setSelectedInstance(instance); } if (props !== undefined) { - allUserProps[props.instanceId] = props; + allUserProps[instance.id] = utils.props.createInstanceProps({ + instanceId: instance.id, + treeId, + props, + }); } } ); @@ -197,15 +201,9 @@ export const usePublishSelectedInstanceData = (treeId: Tree["id"]) => { // Unselects the instance by `undefined` let payload; if (instance !== undefined) { - let props = allUserProps[instance.id]; - if (props === undefined) { - props = { - id: ObjectId().toString(), - instanceId: instance.id, - treeId, - props: [], - }; - } + const props = + allUserProps[instance.id] ?? + utils.props.createInstanceProps({ instanceId: instance.id, treeId }); payload = { id: instance.id, component: instance.component, diff --git a/apps/designer/app/canvas/shared/use-shortcuts.ts b/apps/designer/app/canvas/shared/use-shortcuts.ts index d9fe167cae..a2a5001f1d 100644 --- a/apps/designer/app/canvas/shared/use-shortcuts.ts +++ b/apps/designer/app/canvas/shared/use-shortcuts.ts @@ -18,8 +18,6 @@ declare module "~/shared/pubsub" { selectInstance?: SelectedInstanceData; togglePreviewMode: undefined; zoom: "zoomOut" | "zoomIn"; - copy: undefined; - paste: undefined; } } @@ -67,14 +65,6 @@ const publishCancelCurrentDrag = () => { publish({ type: "cancelCurrentDrag" }); }; -const publishCopy = () => { - publish({ type: "copy" }); -}; - -const publishPaste = () => { - publish({ type: "paste" }); -}; - export const useShortcuts = () => { const [selectedInstance, setSelectedInstance] = useSelectedInstance(); const [editingInstanceId, setEditingInstanceId] = useTextEditingInstanceId(); @@ -94,8 +84,6 @@ export const useShortcuts = () => { redo: store.redo.bind(store), delete: publishDeleteInstance, preview: togglePreviewMode, - copy: publishCopy, - paste: publishPaste, breakpointsMenu: publishOpenBreakpointsMenu, breakpoint: publishSelectBreakpoint, zoom: publishZoom, @@ -147,19 +135,6 @@ export const useShortcuts = () => { useHotkeys(shortcuts.preview, shortcutHandlerMap.preview, options, []); - useHotkeys( - shortcuts.copy, - shortcutHandlerMap.copy, - { ...options, enableOnTags: [...inputTags] }, - [] - ); - useHotkeys( - shortcuts.paste, - shortcutHandlerMap.paste, - { ...options, enableOnTags: [...inputTags] }, - [] - ); - useHotkeys(shortcuts.breakpoint, shortcutHandlerMap.breakpoint, options, []); useHotkeys(shortcuts.zoom, shortcutHandlerMap.zoom, options, []); useHotkeys( diff --git a/apps/designer/app/designer/designer.tsx b/apps/designer/app/designer/designer.tsx index cf8261e369..93e66dd7a4 100644 --- a/apps/designer/app/designer/designer.tsx +++ b/apps/designer/app/designer/designer.tsx @@ -40,7 +40,7 @@ import { useClientSettings } from "./shared/client-settings"; import { Navigator } from "./features/sidebar-left"; import { useSetAssets } from "./shared/assets"; import { getBuildUrl } from "~/shared/router-utils"; -import { useSubscribeCopyPaste } from "~/shared/copy-paste"; +import { useInstanceCopyPaste } from "~/shared/copy-paste"; export const links = () => { return [ @@ -97,12 +97,39 @@ const useNavigatorLayout = () => { return isLoaded ? clientSettings.navigatorLayout : "docked"; }; -export const useSubscribeCanvasReady = (publish: Publish) => { +const useSubscribeCanvasReady = (publish: Publish) => { useSubscribe("canvasReady", () => { publish({ type: "canvasReadyAck" }); }); }; +const useCopyPaste = (publish: Publish) => { + const [selectedInstance] = useSelectedInstanceData(); + const [rootInstance] = useRootInstance(); + + const selectedInstanceData = useMemo(() => { + if (selectedInstance && rootInstance) { + const instance = projectUtils.tree.findInstanceById( + rootInstance, + selectedInstance.id + ); + return instance && { instance, props: selectedInstance.props.props }; + } + }, [rootInstance, selectedInstance]); + + // We need to initialize this in both canvas and designer, + // because the events will fire in either one, depending on where the focus is + useInstanceCopyPaste({ + selectedInstanceData, + onCut: (instance) => { + publish({ type: "deleteInstance", payload: { id: instance.id } }); + }, + onPaste: (instance, props) => { + publish({ type: "insertInstance", payload: { instance, props } }); + }, + }); +}; + type SidePanelProps = { children: JSX.Element | Array; isPreviewMode: boolean; @@ -273,7 +300,7 @@ export const Designer = ({ const { onRef: onRefReadCanvas, onTransitionEnd } = useReadCanvasRect(); const [dragAndDropState] = useDragAndDropState(); useSubscribeCanvasReady(publish); - useSubscribeCopyPaste(publish); + useCopyPaste(publish); const iframeRefCallback = useCallback( (ref) => { diff --git a/apps/designer/app/shared/copy-paste.ts b/apps/designer/app/shared/copy-paste.ts deleted file mode 100644 index 008d4be7f1..0000000000 --- a/apps/designer/app/shared/copy-paste.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { type InstanceProps, type Instance } from "@webstudio-is/react-sdk"; -import { utils } from "@webstudio-is/project"; -import { cloneProps } from "~/shared/props-utils"; -import { useSubscribe, type Publish } from "~/shared/pubsub"; -import { useSelectedInstanceData } from "~/designer/shared/nano-states"; -import { useRootInstance } from "./nano-states"; - -let currentData: - | { instance: Instance; props: InstanceProps | undefined } - | undefined; - -export const useSubscribeCopyPaste = (publish: Publish) => { - const [selectedInstanceData] = useSelectedInstanceData(); - const [rootInstance] = useRootInstance(); - - useSubscribe("copy", () => { - if (selectedInstanceData === undefined || rootInstance === undefined) { - return; - } - const instance = utils.tree.findInstanceById( - rootInstance, - selectedInstanceData.id - ); - if (instance === undefined) { - return; - } - currentData = { - instance, - props: - selectedInstanceData.props.props.length > 0 - ? selectedInstanceData.props - : undefined, - }; - }); - - useSubscribe("paste", () => { - if (currentData === undefined) { - return; - } - - const instance = utils.tree.cloneInstance(currentData.instance); - - const props = currentData.props - ? cloneProps(currentData.props, { instanceId: instance.id }) - : undefined; - - publish({ - type: "insertInstance", - payload: { instance, props }, - }); - }); -}; diff --git a/apps/designer/app/shared/copy-paste/index.ts b/apps/designer/app/shared/copy-paste/index.ts new file mode 100644 index 0000000000..124a3b4d28 --- /dev/null +++ b/apps/designer/app/shared/copy-paste/index.ts @@ -0,0 +1,2 @@ +export * from "./use-instance-copy-paste"; +export * from "./serialize"; diff --git a/apps/designer/app/shared/copy-paste/serialize.ts b/apps/designer/app/shared/copy-paste/serialize.ts new file mode 100644 index 0000000000..08846486d5 --- /dev/null +++ b/apps/designer/app/shared/copy-paste/serialize.ts @@ -0,0 +1,25 @@ +import { Instance, UserProp } from "@webstudio-is/react-sdk"; +import { z } from "zod"; + +const TYPE = "@webstudio/instance/v1" as const; + +const InstanceCopyData = z.object({ + instance: Instance, + props: z.array(UserProp).optional(), +}); + +export type InstanceCopyData = z.infer; + +const SerialisedData = z.object({ [TYPE]: InstanceCopyData }); + +export const serialize = (data: InstanceCopyData) => { + return JSON.stringify({ [TYPE]: data }); +}; + +export const deserialize = (text: string) => { + try { + return SerialisedData.parse(JSON.parse(text))[TYPE]; + } catch (e) { + return; + } +}; diff --git a/apps/designer/app/shared/copy-paste/use-instance-copy-paste.ts b/apps/designer/app/shared/copy-paste/use-instance-copy-paste.ts new file mode 100644 index 0000000000..578c7b9a16 --- /dev/null +++ b/apps/designer/app/shared/copy-paste/use-instance-copy-paste.ts @@ -0,0 +1,130 @@ +import { utils } from "@webstudio-is/project"; +import type { Instance, UserProp } from "@webstudio-is/react-sdk"; +import { useEffect, useRef } from "react"; +import { type InstanceCopyData, serialize, deserialize } from "./serialize"; + +type Props = { + selectedInstanceData?: InstanceCopyData | undefined; + allowAnyTarget?: boolean; + onPaste?: (instance: Instance, props?: UserProp[]) => void; + onCut?: (instance: Instance) => void; +}; + +export const useInstanceCopyPaste = (props: Props): void => { + const latestProps = useRef(props); + latestProps.current = props; + + useEffect(() => { + const isInstanceClipboardEvent = (event: ClipboardEvent) => { + const selection = document.getSelection(); + if (selection !== null && selection.type === "Range") { + return false; + } + + // Note on event.target: + // + // The spec (https://w3c.github.io/clipboard-apis/#to-fire-a-clipboard-event) + // says that if the context is not editable, the target should be the focused node. + // + // But in practice it seems that the target is based + // on where the cursor is, rather than which element has focus. + // For example, if a