mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 22:14:29 +09:00
609 lines
18 KiB
TypeScript
609 lines
18 KiB
TypeScript
import {
|
|
detectFragmentTokenConflicts,
|
|
extractWebstudioFragment,
|
|
insertWebstudioFragmentCopy,
|
|
} from "@webstudio-is/project-build/runtime/fragment";
|
|
import { findClosestInsertable } from "../instance-utils/insert";
|
|
import {
|
|
updateInstanceData,
|
|
updateWebstudioData,
|
|
getWebstudioData,
|
|
} from "../instance-utils/data";
|
|
import {
|
|
insertInstanceChildrenMutable,
|
|
type Insertable,
|
|
} from "../instance-utils/insert";
|
|
import { shallowEqual } from "shallow-equal";
|
|
import { z } from "zod";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
type Instance,
|
|
type Instances,
|
|
type WebstudioFragment,
|
|
webstudioFragment,
|
|
findTreeInstanceIdsExcludingSlotDescendants,
|
|
isComponentDetachable,
|
|
portalComponent,
|
|
} from "@webstudio-is/sdk";
|
|
import { $instances, $project } from "~/shared/sync/data-stores";
|
|
import type { InstanceSelector } from "../instance-utils/tree";
|
|
import { findChildReferenceIndex } from "@webstudio-is/project-build/runtime/instances";
|
|
import {
|
|
deleteInstanceMutable,
|
|
sortInstancePathsForChildMutation,
|
|
} from "../instance-utils/mutation";
|
|
import {
|
|
$allSelectedInstanceSelectors,
|
|
clearInstanceSelection,
|
|
getInstancePath,
|
|
$selectedInstancePath,
|
|
$selectedInstanceSelector,
|
|
selectInstances,
|
|
} from "~/shared/nano-states";
|
|
import { findAvailableVariables } from "@webstudio-is/project-build/runtime/data";
|
|
import { builderApi } from "../builder-api";
|
|
import type { Plugin } from "./copy-paste";
|
|
import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime/breakpoints";
|
|
|
|
const version = "@webstudio/instance/v0.1";
|
|
const multiRootVersion = "@webstudio/instances/v0.1";
|
|
|
|
const instanceData = webstudioFragment.extend({
|
|
instanceSelector: z.array(z.string()),
|
|
});
|
|
|
|
type InstanceData = z.infer<typeof instanceData>;
|
|
|
|
const multiRootInstanceData = z.object({
|
|
rootInstanceIds: z.array(z.string()),
|
|
fragment: webstudioFragment,
|
|
});
|
|
|
|
type MultiRootInstanceData = z.infer<typeof multiRootInstanceData>;
|
|
|
|
const getTreeData = (
|
|
instanceSelector: InstanceSelector,
|
|
{ showToast = true }: { showToast?: boolean } = {}
|
|
) => {
|
|
const instances = $instances.get();
|
|
const [targetInstanceId] = instanceSelector;
|
|
const instance = instances.get(targetInstanceId);
|
|
if (instance && !isComponentDetachable(instance.component)) {
|
|
if (showToast) {
|
|
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 stringifyMultiRoot = (data: MultiRootInstanceData) => {
|
|
return JSON.stringify({ [multiRootVersion]: data });
|
|
};
|
|
|
|
const stringifyMultiRootSelection = (selectedData: InstanceData[]) => {
|
|
const rootInstanceIds = selectedData.map((data) => data.instanceSelector[0]);
|
|
return stringifyMultiRoot({
|
|
rootInstanceIds,
|
|
fragment: mergeWebstudioFragments(rootInstanceIds, selectedData),
|
|
});
|
|
};
|
|
|
|
const clipboard = z.object({ [version]: instanceData });
|
|
const multiRootClipboard = z.object({
|
|
[multiRootVersion]: multiRootInstanceData,
|
|
});
|
|
|
|
const parse = (clipboardData: string): InstanceData | undefined => {
|
|
try {
|
|
const data = clipboard.parse(JSON.parse(clipboardData));
|
|
return data[version];
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
|
|
const parseMultiRoot = (
|
|
clipboardData: string
|
|
): MultiRootInstanceData | undefined => {
|
|
try {
|
|
const data = multiRootClipboard.parse(JSON.parse(clipboardData));
|
|
return data[multiRootVersion];
|
|
} catch {
|
|
return;
|
|
}
|
|
};
|
|
|
|
const getPasteRootInstanceIds = ({
|
|
rootInstanceIds,
|
|
fragment,
|
|
}: MultiRootInstanceData) => {
|
|
const instanceIds = new Set(
|
|
fragment.instances.map((instance) => instance.id)
|
|
);
|
|
const fragmentRootIds = new Set<Instance["id"]>();
|
|
for (const child of fragment.children) {
|
|
if (child.type === "id") {
|
|
fragmentRootIds.add(child.value);
|
|
}
|
|
}
|
|
const seen = new Set<Instance["id"]>();
|
|
return rootInstanceIds.filter((instanceId) => {
|
|
if (
|
|
instanceIds.has(instanceId) === false ||
|
|
fragmentRootIds.has(instanceId) === false ||
|
|
seen.has(instanceId)
|
|
) {
|
|
return false;
|
|
}
|
|
seen.add(instanceId);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const mergeById = <Item extends { id: string }>(items: Item[]) =>
|
|
Array.from(new Map(items.map((item) => [item.id, item])).values());
|
|
|
|
const mergeUniqueByJson = <Item>(items: Item[]) =>
|
|
Array.from(
|
|
new Map(items.map((item) => [JSON.stringify(item), item])).values()
|
|
);
|
|
|
|
const mergeWebstudioFragments = (
|
|
rootInstanceIds: Instance["id"][],
|
|
fragments: WebstudioFragment[]
|
|
): WebstudioFragment => ({
|
|
children: rootInstanceIds.map((instanceId) => ({
|
|
type: "id" as const,
|
|
value: instanceId,
|
|
})),
|
|
instances: mergeById(fragments.flatMap((fragment) => fragment.instances)),
|
|
styleSourceSelections: mergeUniqueByJson(
|
|
fragments.flatMap((fragment) => fragment.styleSourceSelections)
|
|
),
|
|
styleSources: mergeById(
|
|
fragments.flatMap((fragment) => fragment.styleSources)
|
|
),
|
|
breakpoints: mergeById(fragments.flatMap((fragment) => fragment.breakpoints)),
|
|
styles: mergeUniqueByJson(fragments.flatMap((fragment) => fragment.styles)),
|
|
dataSources: mergeById(fragments.flatMap((fragment) => fragment.dataSources)),
|
|
resources: mergeById(fragments.flatMap((fragment) => fragment.resources)),
|
|
props: mergeById(fragments.flatMap((fragment) => fragment.props)),
|
|
assets: mergeById(fragments.flatMap((fragment) => fragment.assets)),
|
|
});
|
|
|
|
const reportSkippedSelectedInstances = (operation: "copied" | "cut") => {
|
|
builderApi.toast.info(`Some selected instances could not be ${operation}.`);
|
|
};
|
|
|
|
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 getCommonAncestorSelector = (
|
|
instanceSelectors: InstanceSelector[]
|
|
): undefined | InstanceSelector => {
|
|
const [firstSelector] = instanceSelectors;
|
|
if (firstSelector === undefined) {
|
|
return;
|
|
}
|
|
const ancestorSelector: InstanceSelector = [];
|
|
for (let index = 1; index <= firstSelector.length; index += 1) {
|
|
const ancestorId = firstSelector[firstSelector.length - index];
|
|
if (
|
|
instanceSelectors.every(
|
|
(selector) => selector[selector.length - index] === ancestorId
|
|
)
|
|
) {
|
|
ancestorSelector.unshift(ancestorId);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return ancestorSelector.length > 0 ? ancestorSelector : undefined;
|
|
};
|
|
|
|
const findMultiSelectionInsertable = (
|
|
fragment: WebstudioFragment
|
|
): undefined | Insertable => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length < 2) {
|
|
return;
|
|
}
|
|
const instances = $instances.get();
|
|
const selectedPaths = selectedInstanceSelectors
|
|
.map((instanceSelector) => getInstancePath(instanceSelector, instances))
|
|
.filter((path): path is NonNullable<typeof path> => path !== undefined);
|
|
if (selectedPaths.length < 2) {
|
|
return;
|
|
}
|
|
|
|
const directParentSelector = selectedPaths[0][1]?.instanceSelector;
|
|
const hasSameDirectParent =
|
|
directParentSelector !== undefined &&
|
|
selectedPaths.every((path) =>
|
|
shallowEqual(path[1]?.instanceSelector, directParentSelector)
|
|
);
|
|
if (hasSameDirectParent) {
|
|
const parentInstance = instances.get(directParentSelector[0]);
|
|
if (parentInstance === undefined) {
|
|
return;
|
|
}
|
|
const selectedSiblingIndexes = selectedPaths.map((path) =>
|
|
findChildReferenceIndex(parentInstance.children, path[0].instance.id)
|
|
);
|
|
if (selectedSiblingIndexes.includes(-1)) {
|
|
return;
|
|
}
|
|
const lastSelectedSiblingIndex = Math.max(...selectedSiblingIndexes);
|
|
return findPasteTargetForFragment(fragment, {
|
|
parentSelector: directParentSelector,
|
|
position: lastSelectedSiblingIndex + 1,
|
|
});
|
|
}
|
|
|
|
const commonAncestorSelector = getCommonAncestorSelector(
|
|
selectedPaths.map((path) => path[0].instanceSelector)
|
|
);
|
|
if (commonAncestorSelector === undefined) {
|
|
return;
|
|
}
|
|
return findPasteTargetForFragment(fragment, {
|
|
parentSelector: commonAncestorSelector,
|
|
position: "end",
|
|
});
|
|
};
|
|
|
|
const findSelectionPasteTarget = (fragment: WebstudioFragment) =>
|
|
findMultiSelectionInsertable(fragment) ??
|
|
findPasteTargetForFragment(fragment);
|
|
|
|
const getCopiedRootInstanceIds = (fragment: WebstudioFragment) => {
|
|
const newInstances: Instances = new Map(
|
|
fragment.instances.map((instance) => [instance.id, instance])
|
|
);
|
|
const copiedRootInstanceIds = new Set<Instance["id"]>();
|
|
for (const child of fragment.children) {
|
|
if (child.type !== "id") {
|
|
continue;
|
|
}
|
|
for (const instanceId of findTreeInstanceIdsExcludingSlotDescendants(
|
|
newInstances,
|
|
child.value
|
|
)) {
|
|
copiedRootInstanceIds.add(instanceId);
|
|
}
|
|
}
|
|
return copiedRootInstanceIds;
|
|
};
|
|
|
|
const findPasteTargetForFragment = (
|
|
fragment: WebstudioFragment,
|
|
insertable?: Insertable
|
|
): undefined | Insertable => {
|
|
const instances = $instances.get();
|
|
|
|
insertable = findClosestInsertable(fragment, insertable);
|
|
if (insertable === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (fragment.instances.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const copiedRootInstanceIds = getCopiedRootInstanceIds(fragment);
|
|
const preservedChildIds = new Set<Instance["id"]>();
|
|
for (const instance of fragment.instances) {
|
|
for (const child of instance.children) {
|
|
if (
|
|
child.type === "id" &&
|
|
copiedRootInstanceIds.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 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,
|
|
};
|
|
}
|
|
|
|
return findSelectionPasteTarget(data);
|
|
};
|
|
|
|
const resolveFragmentTokenConflicts = async (fragment: WebstudioFragment) => {
|
|
const conflicts = detectFragmentTokenConflicts({
|
|
fragment,
|
|
targetData: getWebstudioData(),
|
|
});
|
|
return conflicts.length > 0
|
|
? await builderApi.showTokenConflictDialog(conflicts)
|
|
: "theirs";
|
|
};
|
|
|
|
const handlePasteInstance = async (clipboardData: string) => {
|
|
const project = $project.get();
|
|
const multiRootFragment = parseMultiRoot(clipboardData);
|
|
if (multiRootFragment !== undefined) {
|
|
if (project === undefined) {
|
|
return false;
|
|
}
|
|
const pasteRootInstanceIds = getPasteRootInstanceIds(multiRootFragment);
|
|
if (pasteRootInstanceIds.length === 0) {
|
|
return false;
|
|
}
|
|
const pasteTarget = findSelectionPasteTarget(multiRootFragment.fragment);
|
|
if (pasteTarget === undefined) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const conflictResolution = await resolveFragmentTokenConflicts(
|
|
multiRootFragment.fragment
|
|
);
|
|
let didInsert = false;
|
|
updateWebstudioData((data) => {
|
|
const { newInstanceIds } = insertWebstudioFragmentCopy({
|
|
data,
|
|
fragment: multiRootFragment.fragment,
|
|
availableVariables: findAvailableVariables({
|
|
...data,
|
|
startingInstanceId: pasteTarget.parentSelector[0],
|
|
}),
|
|
projectId: project.id,
|
|
conflictResolution,
|
|
onBreakpointLimitMerge: () => {
|
|
toast.warn(breakpointPasteLimitWarning);
|
|
},
|
|
});
|
|
const children: Instance["children"] = [];
|
|
const newSelectedSelectors: InstanceSelector[] = [];
|
|
for (const rootInstanceId of pasteRootInstanceIds) {
|
|
const newRootInstanceId = newInstanceIds.get(rootInstanceId);
|
|
if (newRootInstanceId === undefined) {
|
|
continue;
|
|
}
|
|
children.push({ type: "id", value: newRootInstanceId });
|
|
newSelectedSelectors.push([
|
|
newRootInstanceId,
|
|
...pasteTarget.parentSelector,
|
|
]);
|
|
}
|
|
if (children.length === 0) {
|
|
return;
|
|
}
|
|
insertInstanceChildrenMutable(data, children, pasteTarget);
|
|
selectInstances(newSelectedSelectors);
|
|
didInsert = true;
|
|
});
|
|
if (didInsert === false) {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
// User cancelled
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
const fragment = parse(clipboardData);
|
|
if (fragment === undefined || project === undefined) {
|
|
return false;
|
|
}
|
|
|
|
const pasteTarget = findPasteTarget(fragment);
|
|
if (pasteTarget === undefined) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const conflictResolution = await resolveFragmentTokenConflicts(fragment);
|
|
let didInsert = false;
|
|
updateWebstudioData((data) => {
|
|
const { newInstanceIds } = insertWebstudioFragmentCopy({
|
|
data,
|
|
fragment,
|
|
availableVariables: findAvailableVariables({
|
|
...data,
|
|
startingInstanceId: pasteTarget.parentSelector[0],
|
|
}),
|
|
projectId: project.id,
|
|
conflictResolution,
|
|
onBreakpointLimitMerge: () => {
|
|
toast.warn(breakpointPasteLimitWarning);
|
|
},
|
|
});
|
|
const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id);
|
|
if (newRootInstanceId === undefined) {
|
|
return;
|
|
}
|
|
const children: Instance["children"] = [
|
|
{ type: "id", value: newRootInstanceId },
|
|
];
|
|
insertInstanceChildrenMutable(data, children, pasteTarget);
|
|
selectInstances([[newRootInstanceId, ...pasteTarget.parentSelector]]);
|
|
didInsert = true;
|
|
});
|
|
if (didInsert === false) {
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
// User cancelled
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
const handleCopyInstance = () => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length === 0) {
|
|
return;
|
|
}
|
|
if (selectedInstanceSelectors.length === 1) {
|
|
const data = getTreeData(selectedInstanceSelectors[0]);
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
return stringify(data);
|
|
}
|
|
|
|
const selectedData = selectedInstanceSelectors
|
|
.map((instanceSelector) =>
|
|
getTreeData(instanceSelector, { showToast: false })
|
|
)
|
|
.filter((data): data is InstanceData => data !== undefined);
|
|
if (selectedData.length === 0) {
|
|
return;
|
|
}
|
|
if (selectedData.length < selectedInstanceSelectors.length) {
|
|
reportSkippedSelectedInstances("copied");
|
|
}
|
|
return stringifyMultiRootSelection(selectedData);
|
|
};
|
|
|
|
const handleCutInstance = () => {
|
|
const selectedInstanceSelectors = $allSelectedInstanceSelectors.get();
|
|
if (selectedInstanceSelectors.length > 1) {
|
|
const instances = $instances.get();
|
|
const selectedPaths = selectedInstanceSelectors
|
|
.map((instanceSelector) => {
|
|
const data = getTreeData(instanceSelector, { showToast: false });
|
|
const instancePath =
|
|
data === undefined
|
|
? undefined
|
|
: getInstancePath(data.instanceSelector, instances);
|
|
if (data === undefined || instancePath === undefined) {
|
|
return;
|
|
}
|
|
return { data, instancePath };
|
|
})
|
|
.filter(
|
|
(
|
|
item
|
|
): item is {
|
|
data: InstanceData;
|
|
instancePath: NonNullable<ReturnType<typeof getInstancePath>>;
|
|
} => item !== undefined
|
|
);
|
|
if (selectedPaths.length === 0) {
|
|
return;
|
|
}
|
|
const selectedPathData = selectedPaths.map(({ data }) => data);
|
|
if (selectedPathData.length < selectedInstanceSelectors.length) {
|
|
reportSkippedSelectedInstances("cut");
|
|
}
|
|
const clipboardData = stringifyMultiRootSelection(selectedPathData);
|
|
updateInstanceData((data) => {
|
|
for (const { instancePath } of sortInstancePathsForChildMutation(
|
|
selectedPaths
|
|
)) {
|
|
deleteInstanceMutable(data, instancePath);
|
|
}
|
|
clearInstanceSelection();
|
|
});
|
|
return clipboardData;
|
|
}
|
|
|
|
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;
|
|
}
|
|
updateInstanceData((data) => {
|
|
deleteInstanceMutable(data, instancePath);
|
|
});
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
return stringify(data);
|
|
};
|
|
|
|
export const instanceText: Plugin = {
|
|
name: "instance-text",
|
|
mimeType: "text/plain",
|
|
onCopy: handleCopyInstance,
|
|
onCut: handleCutInstance,
|
|
onPaste: handlePasteInstance,
|
|
};
|
|
|
|
export const instanceJson: Plugin = {
|
|
name: "instance-json",
|
|
mimeType: "application/json",
|
|
onPaste: handlePasteInstance,
|
|
};
|