Files
Oleg Isonen 11ba996f13 feat: New token conflict resolution logic (#5524)
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
2025-12-18 17:31:31 +01:00

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,
};