Files
webstudio/apps/builder/app/shared/copy-paste/plugin-instance.ts
T
Bogdan Chadkin 8e1683ed86 refactor: switch to instances slice for copy paste (#2666)
Use same well tested utility to slice and insert its copy for copy
paste. Moved most utilities inside of instance plugin and added more
tests for it.

## Steps for reproduction

1. play with copy paste
2023-12-06 23:49:48 +07:00

275 lines
7.2 KiB
TypeScript

import { shallowEqual } from "shallow-equal";
import { z } from "zod";
import { toast } from "@webstudio-is/design-system";
import {
Asset,
Breakpoint,
DataSource,
Instance,
Instances,
Prop,
StyleDecl,
StyleSource,
StyleSourceSelection,
findTreeInstanceIdsExcludingSlotDescendants,
} from "@webstudio-is/sdk";
import {
$selectedInstanceSelector,
$selectedPage,
$project,
$registeredComponentMetas,
$instances,
$dataSources,
} from "../nano-states";
import {
type InstanceSelector,
type DroppableTarget,
getInstanceOrCreateFragmentIfNecessary,
wrapEditableChildrenAroundDropTargetMutable,
} from "../tree-utils";
import {
computeInstancesConstraints,
deleteInstance,
findAvailableDataSources,
findClosestDroppableTarget,
getInstancesSlice,
insertInstancesSliceCopy,
isInstanceDetachable,
} from "../instance-utils";
import { portalComponent } from "@webstudio-is/react-sdk";
const version = "@webstudio/instance/v0.1";
const InstanceData = z.object({
instanceSelector: z.array(z.string()),
breakpoints: z.array(Breakpoint),
instances: z.array(Instance),
props: z.array(Prop),
dataSources: z.array(DataSource),
styleSourceSelections: z.array(StyleSourceSelection),
styleSources: z.array(StyleSource),
styles: z.array(StyleDecl),
assets: z.array(Asset),
});
type InstanceData = z.infer<typeof InstanceData>;
const getTreeData = (targetInstanceSelector: InstanceSelector) => {
if (isInstanceDetachable(targetInstanceSelector) === false) {
toast.error(
"This instance can not be moved outside of its parent component."
);
return;
}
// @todo tell user they can't copy or cut root
if (targetInstanceSelector.length === 1) {
return;
}
const [targetInstanceId] = targetInstanceSelector;
return {
instanceSelector: targetInstanceSelector,
...getInstancesSlice(targetInstanceId),
};
};
const stringify = (data: InstanceData) => {
return JSON.stringify({ [version]: data });
};
const ClipboardData = z.object({ [version]: InstanceData });
const parse = (clipboardData: string): InstanceData | undefined => {
try {
const data = ClipboardData.parse(JSON.parse(clipboardData));
return data[version];
} catch {
return;
}
};
export const mimeType = "application/json";
export const getPortalFragmentSelector = (
instances: Instances,
instanceSelector: InstanceSelector
) => {
const instance = instances.get(instanceSelector[0]);
if (
instance?.component !== portalComponent ||
instance.children.length === 0 ||
instance.children[0].type !== "id"
) {
return;
}
// first portal child is always fragment
return [instance.children[0].value, ...instanceSelector];
};
const getPasteTarget = (
data: InstanceData,
instanceSelector: InstanceSelector
): undefined | DroppableTarget => {
const instances = $instances.get();
// paste after selected instance
if (shallowEqual(instanceSelector, data.instanceSelector)) {
// body is not allowed to copy
// so clipboard always have at least two level instance selector
const [currentInstanceId, parentInstanceId] = instanceSelector;
const parentInstance = instances.get(parentInstanceId);
if (parentInstance === undefined) {
return;
}
const indexWithinChildren = parentInstance.children.findIndex(
(child) => child.type === "id" && child.value === currentInstanceId
);
return {
parentSelector: instanceSelector.slice(1),
position: indexWithinChildren + 1,
};
}
const newInstances: Instances = new Map();
for (const instance of data.instances) {
newInstances.set(instance.id, instance);
}
const metas = $registeredComponentMetas.get();
const rootInstanceId = data.instances[0].id;
const pasteTarget = findClosestDroppableTarget(
metas,
instances,
instanceSelector,
computeInstancesConstraints(metas, newInstances, [rootInstanceId])
);
if (pasteTarget === undefined) {
return;
}
const newInstanceIds = findTreeInstanceIdsExcludingSlotDescendants(
newInstances,
data.instances[0].id
);
const preservedChildIds = new Set<Instance["id"]>();
for (const instance of data.instances) {
for (const child of instance.children) {
if (child.type === "id" && newInstanceIds.has(child.value) === false) {
preservedChildIds.add(child.value);
}
}
}
// portal descendants ids are preserved
// so need to prevent pasting portal inside its copies
// to avoid circular tree
const dropTargetSelector =
// consider portal fragment when check for cycles to avoid cases
// like pasting portal directly into portal
getPortalFragmentSelector(instances, pasteTarget.parentSelector) ??
pasteTarget.parentSelector;
for (const instanceId of dropTargetSelector) {
if (preservedChildIds.has(instanceId)) {
return;
}
}
return pasteTarget;
};
export const onPaste = (clipboardData: string): boolean => {
const data = parse(clipboardData);
const selectedPage = $selectedPage.get();
const project = $project.get();
const metas = $registeredComponentMetas.get();
if (
data === undefined ||
selectedPage === undefined ||
project === undefined
) {
return false;
}
// paste to the root if nothing is selected
const instanceSelector = $selectedInstanceSelector.get() ?? [
selectedPage.rootInstanceId,
];
const pasteTarget = getPasteTarget(data, instanceSelector);
if (pasteTarget === undefined) {
return false;
}
insertInstancesSliceCopy({
slice: data,
availableDataSources: findAvailableDataSources(
$dataSources.get(),
instanceSelector
),
beforeTransactionEnd: (rootInstanceId, draft) => {
let dropTarget = pasteTarget;
dropTarget =
getInstanceOrCreateFragmentIfNecessary(draft.instances, dropTarget) ??
dropTarget;
dropTarget =
wrapEditableChildrenAroundDropTargetMutable(
draft.instances,
draft.props,
metas,
dropTarget
) ?? dropTarget;
const [parentId] = dropTarget.parentSelector;
const parentInstance = draft.instances.get(parentId);
if (parentInstance === undefined) {
return;
}
const child: Instance["children"][number] = {
type: "id",
value: rootInstanceId,
};
const { position } = dropTarget;
if (position === "end") {
parentInstance.children.push(child);
} else {
parentInstance.children.splice(position, 0, child);
}
},
});
return true;
};
export const onCopy = () => {
const selectedInstanceSelector = $selectedInstanceSelector.get();
if (selectedInstanceSelector === undefined) {
return;
}
const data = getTreeData(selectedInstanceSelector);
if (data === undefined) {
return;
}
return stringify(data);
};
export const onCut = () => {
const selectedInstanceSelector = $selectedInstanceSelector.get();
if (selectedInstanceSelector === undefined) {
return;
}
// @todo tell user they can't delete root
if (selectedInstanceSelector.length === 1) {
return;
}
const data = getTreeData(selectedInstanceSelector);
if (data === undefined) {
return;
}
deleteInstance(selectedInstanceSelector);
if (data === undefined) {
return;
}
return stringify(data);
};