Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make copy paste data agnostic #909

Merged
merged 4 commits into from
Feb 1, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/designer/app/shared/copy-paste/copy-paste-instance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import ObjectId from "bson-objectid";
import { useEffect } from "react";
import { z } from "zod";
import { Instance, Props, Styles } from "@webstudio-is/project-build";
import { utils } from "@webstudio-is/project";
import { startCopyPaste } from "./copy-paste";

const InstanceCopyData = z.object({
instance: Instance,
props: Props,
styles: Styles,
});

export type InstanceCopyData = z.infer<typeof InstanceCopyData>;

type InstanceCopyPasteProps = {
selectedInstanceData?: InstanceCopyData;
allowAnyTarget?: boolean;
onPaste: (data: InstanceCopyData) => void;
onCut: (instance: Instance) => void;
};

// to make it easier to remove React from canvas if we need to
export const useInstanceCopyPaste = (props: InstanceCopyPasteProps): void => {
const { selectedInstanceData, allowAnyTarget, onPaste, onCut } = props;
useEffect(() => {
return startCopyPaste({
version: "@webstudio/instance/v0.1",
TrySound marked this conversation as resolved.
Show resolved Hide resolved
type: InstanceCopyData,
onCopy: () => {
return selectedInstanceData;
},
onCut: () => {
if (selectedInstanceData === undefined) {
return;
}
onCut(selectedInstanceData.instance);
return selectedInstanceData;
},
onPaste: (data: InstanceCopyData) => {
const instance = utils.tree.cloneInstance(data.instance);

// copy props with new ids and link to new instance
const props: Props = data.props.map((prop) => {
return {
...prop,
id: ObjectId().toString(),
instanceId: instance.id,
};
});

const styles: Styles = data.styles.map((styleDecl) => {
return {
...styleDecl,
instanceId: instance.id,
};
});

onPaste({ instance, props, styles });
},
});
}, [selectedInstanceData, allowAnyTarget, onPaste, onCut]);
TrySound marked this conversation as resolved.
Show resolved Hide resolved
};
130 changes: 130 additions & 0 deletions apps/designer/app/shared/copy-paste/copy-paste.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { z } from "zod";

const isValidClipboardEvent = (
TrySound marked this conversation as resolved.
Show resolved Hide resolved
event: ClipboardEvent,
options: { allowAnyTarget: boolean }
) => {
const selection = document.getSelection();
if (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 (we may want to add more exceptions here in the future)
if (
options.allowAnyTarget === false &&
(event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement)
) {
return false;
}

return true;
};

type Props<Data> = {
version: string;
type: z.ZodType<Data>;
allowAnyTarget?: boolean;
onCopy: () => undefined | Data;
onCut: () => undefined | Data;
onPaste: (data: Data) => void;
};

export const startCopyPaste = <Type>(props: Props<Type>) => {
const { version, type, allowAnyTarget = false } = props;
const versionLiteral = version;

const DataType = z.object({ [versionLiteral]: type });

const serialize = (data: Type) => {
return JSON.stringify({ [versionLiteral]: data });
};

const deserialize = (text: string) => {
try {
const data = DataType.parse(JSON.parse(text));
// zod provides invalid type without versionLiteral
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (data as any)[versionLiteral] as Type;
} catch {
return;
}
};

const handleCopy = (event: ClipboardEvent) => {
if (
event.clipboardData === null ||
isValidClipboardEvent(event, { allowAnyTarget }) === false
) {
return;
}

const data = props.onCopy();
if (data === undefined) {
return;
}

// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData.setData("application/json", serialize(data));
};

const handleCut = (event: ClipboardEvent) => {
if (
event.clipboardData === null ||
isValidClipboardEvent(event, { allowAnyTarget }) === false
) {
return;
}

const data = props.onCut();
if (data === undefined) {
return;
}

// must prevent default, otherwise setData() will not work
event.preventDefault();
event.clipboardData.setData("application/json", serialize(data));
};

const handlePaste = (event: ClipboardEvent) => {
if (
event.clipboardData === null ||
// we might want a separate predicate for paste,
// but for now the logic is the same
isValidClipboardEvent(event, { allowAnyTarget }) === false
) {
return;
}

// this shouldn't matter, but just in case
event.preventDefault();
const data = deserialize(event.clipboardData.getData("application/json"));
if (data === undefined) {
return;
}

props.onPaste(data);
};

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);
};
};
3 changes: 1 addition & 2 deletions apps/designer/app/shared/copy-paste/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
export * from "./use-instance-copy-paste";
export * from "./serialize";
export * from "./copy-paste-instance";
26 changes: 0 additions & 26 deletions apps/designer/app/shared/copy-paste/serialize.ts

This file was deleted.

Loading