mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
use clipboard api
This commit is contained in:
1 parent
652a323db2
commit
21befdd3c3
15 files changed
+253
-131
No files matched your search
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user