import { shallowEqual } from "shallow-equal"; import { z } from "zod"; import { toast } from "@webstudio-is/design-system"; import { Instance, Instances, WebstudioFragment, findTreeInstanceIdsExcludingSlotDescendants, isComponentDetachable, portalComponent, } from "@webstudio-is/sdk"; import { $selectedInstanceSelector, $instances, $project, } from "../nano-states"; import type { InstanceSelector } from "../tree-utils"; import { deleteInstanceMutable, extractWebstudioFragment, insertWebstudioFragmentCopy, updateWebstudioData, getWebstudioData, insertInstanceChildrenMutable, findClosestInsertable, insertFragmentWithConflictResolution, type Insertable, } from "../instance-utils"; import { $selectedInstancePath } from "../awareness"; import { findAvailableVariables } from "../data-variables"; import type { Plugin } from "./init-copy-paste"; const version = "@webstudio/instance/v0.1"; const InstanceData = WebstudioFragment.extend({ instanceSelector: z.array(z.string()), }); type InstanceData = z.infer; const getTreeData = (instanceSelector: InstanceSelector) => { const instances = $instances.get(); const [targetInstanceId] = instanceSelector; const instance = instances.get(targetInstanceId); if (instance && !isComponentDetachable(instance.component)) { 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 (instanceSelector.length === 1) { return; } return { instanceSelector, ...extractWebstudioFragment(getWebstudioData(), 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; } }; 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 findPasteTarget = (data: InstanceData): undefined | Insertable => { const instances = $instances.get(); const instanceSelector = $selectedInstanceSelector.get(); // paste after selected instance if ( instanceSelector && 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 insertable = findClosestInsertable(data); if (insertable === undefined) { return; } if (data.instances.length === 0) { return; } const newInstances: Instances = new Map(); for (const instance of data.instances) { newInstances.set(instance.id, instance); } const newInstanceIds = findTreeInstanceIdsExcludingSlotDescendants( newInstances, data.instances[0].id ); const preservedChildIds = new Set(); 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, insertable.parentSelector) ?? insertable.parentSelector; for (const instanceId of dropTargetSelector) { if (preservedChildIds.has(instanceId)) { return; } } return insertable; }; const onPaste = async (clipboardData: string) => { const project = $project.get(); const fragment = parse(clipboardData); if (fragment === undefined || project === undefined) { return false; } const pasteTarget = findPasteTarget(fragment); if (pasteTarget === undefined) { return false; } try { const conflictResolution = await insertFragmentWithConflictResolution({ fragment, }); updateWebstudioData((data) => { const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, availableVariables: findAvailableVariables({ ...data, startingInstanceId: pasteTarget.parentSelector[0], }), projectId: project.id, conflictResolution, }); const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id); if (newRootInstanceId === undefined) { return; } const children: Instance["children"] = [ { type: "id", value: newRootInstanceId }, ]; insertInstanceChildrenMutable(data, children, pasteTarget); }); } catch (error) { // User cancelled return false; } return true; }; const onCopy = () => { const selectedInstanceSelector = $selectedInstanceSelector.get(); if (selectedInstanceSelector === undefined) { return; } const data = getTreeData(selectedInstanceSelector); if (data === undefined) { return; } return stringify(data); }; const onCut = () => { const instancePath = $selectedInstancePath.get(); if (instancePath === undefined) { return; } // @todo tell user they can't delete root if (instancePath.length === 1) { return; } const data = getTreeData(instancePath[0].instanceSelector); if (data === undefined) { return; } updateWebstudioData((data) => { deleteInstanceMutable(data, instancePath); }); if (data === undefined) { return; } return stringify(data); }; export const instanceText: Plugin = { name: "instance-text", mimeType: "text/plain", onCopy, onCut, onPaste, }; export const instanceJson: Plugin = { name: "instance-json", mimeType: "application/json", onPaste, };