mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-28 23:57:17 +09:00
Fixes https://github.com/webstudio-is/webstudio/issues/5491 ## Description - When pasting an instance with tokens, we can compare the name and the contents. If they are identical, we can choose the existing one and avoid having any conflicts. - When pasting a token and the name is already used, add a numeric suffix to it. - find duplicate tokens command that will go based on content even if name is different ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
254 lines
6.5 KiB
TypeScript
254 lines
6.5 KiB
TypeScript
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<typeof InstanceData>;
|
|
|
|
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<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, 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,
|
|
};
|