use clipboard api

This commit is contained in:
Roman Pominov committed 2022-11-28 11:44:15 +04:00
1 parent 652a323db2
commit 21befdd3c3
15 files changed
+253 -131

No files matched your search

+29 -2
View File
@@ -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<Asset>) => {
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;
};
+11 -13
View File
@@ -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<UserProp>;
};
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,
@@ -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(
+30 -3
View File
@@ -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<JSX.Element>;
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) => {
-52
View File
@@ -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 },
});
});
};
@@ -0,0 +1,2 @@
export * from "./use-instance-copy-paste";
export * from "./serialize";
@@ -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<typeof InstanceCopyData>;
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;
}
};
@@ -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>(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 <button> has focus, the target is the <body> element
// (at least in Chrome).
// If cursor is in input,
// don't copy instance (we may want to add more exceptions here in the future)
if (
latestProps.current.allowAnyTarget !== true &&
(event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement)
) {
return false;
}
return true;
};
const handleCopy = (event: ClipboardEvent) => {
const { selectedInstanceData } = latestProps.current;
if (
event.clipboardData === null ||
selectedInstanceData === undefined ||
isInstanceClipboardEvent(event) === false
) {
return;
}
// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData.setData(
"application/json",
serialize(selectedInstanceData)
);
};
const handleCut = (event: ClipboardEvent) => {
const { selectedInstanceData, onCut } = latestProps.current;
if (
event.clipboardData === null ||
selectedInstanceData === undefined ||
isInstanceClipboardEvent(event) === false
) {
return;
}
// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData.setData(
"application/json",
serialize(selectedInstanceData)
);
onCut?.(selectedInstanceData.instance);
};
const handlePaste = (event: ClipboardEvent) => {
const { onPaste } = latestProps.current;
if (
onPaste === undefined ||
event.clipboardData === null ||
// we might want a separate predicate for paste,
// but for now the logic is the same
isInstanceClipboardEvent(event) === false
) {
return;
}
// this shouldn't matter, but just in case
event.preventDefault();
const data = deserialize(event.clipboardData.getData("application/json"));
if (data === undefined) {
return;
}
const instance = utils.tree.cloneInstance(data.instance);
const props =
data.props && data.props.length > 0
? utils.props.cloneUserProps(data.props)
: undefined;
onPaste(instance, props);
};
document.addEventListener("copy", handleCopy);
document.addEventListener("cut", handleCut);
document.addEventListener("paste", handlePaste);
return () => {
document.removeEventListener("copy", handleCopy);
document.removeEventListener("cut", handleCut);
document.removeEventListener("paste", handlePaste);
};
}, []);
};
@@ -1,20 +0,0 @@
import { cloneProps } from "./clone-props";
describe("Clone props", () => {
test("update ids", () => {
const instanceProps = {
props: [{ id: "1", prop: "a", value: "1" }],
id: "id",
instanceId: "instanceId",
treeId: "treeId",
};
const clonedProps = cloneProps(instanceProps, {
instanceId: "newInstanceId",
});
expect(clonedProps.id).not.toBe(instanceProps.id);
expect(clonedProps.instanceId).toBe("newInstanceId");
expect(clonedProps.treeId).toBe(instanceProps.treeId);
expect(clonedProps.props[0].id).not.toBe(instanceProps.props[0].id);
});
});
@@ -1,15 +0,0 @@
import ObjectId from "bson-objectid";
import produce from "immer";
import { type InstanceProps, type Instance } from "@webstudio-is/react-sdk";
export const cloneProps = (
instanceProps: InstanceProps,
{ instanceId }: { instanceId: Instance["id"] }
) =>
produce((instanceProps: InstanceProps) => {
instanceProps.id = ObjectId().toString();
instanceProps.instanceId = instanceId;
for (const prop of instanceProps.props) {
prop.id = ObjectId().toString();
}
})(instanceProps);
@@ -1,3 +1,2 @@
export * from "./update-props";
export * from "./delete-prop";
export * from "./clone-props";
+2
View File
@@ -1,11 +1,13 @@
import * as breakpointsUtils from "./shared/breakpoints";
import * as treeUtils from "./shared/tree-utils";
import * as pagesUtils from "./shared/pages";
import * as propsUtils from "./shared/props";
export const utils = {
breakpoints: breakpointsUtils,
tree: treeUtils,
pages: pagesUtils,
props: propsUtils,
} as const;
export * from "./shared/styles";
@@ -0,0 +1,5 @@
import type { UserProp } from "@webstudio-is/react-sdk";
import ObjectId from "bson-objectid";
export const cloneUserProps = (props: Array<UserProp>): Array<UserProp> =>
props.map((prop) => ({ ...prop, id: ObjectId().toString() }));
@@ -0,0 +1,17 @@
import type { InstanceProps, UserProp } from "@webstudio-is/react-sdk";
import { v4 as uuid } from "uuid";
export const createInstanceProps = ({
treeId,
instanceId,
props,
}: {
treeId: string;
instanceId: string;
props?: Array<UserProp>;
}): InstanceProps => ({
id: uuid(),
treeId,
instanceId,
props: props ?? [],
});
@@ -0,0 +1,2 @@
export * from "./create-instance-props";
export * from "./clone-user-props";