mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
Here added action copy paste support. When all action dependencies are scoped to copied instance new variable nams are ussed. When some any dependency is not scoped execution is removed from copied prop.
609 lines
17 KiB
TypeScript
609 lines
17 KiB
TypeScript
import { shallowEqual } from "shallow-equal";
|
|
import { nanoid } from "nanoid";
|
|
import store from "immerhin";
|
|
import { z } from "zod";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
Breakpoint,
|
|
DataSource,
|
|
findTreeInstanceIds,
|
|
Instance,
|
|
Prop,
|
|
StyleDecl,
|
|
StyleSource,
|
|
StyleSourceSelection,
|
|
StyleSourceSelections,
|
|
} from "@webstudio-is/project-build";
|
|
import { Asset } from "@webstudio-is/asset-uploader";
|
|
import {
|
|
encodeDataSourceVariable,
|
|
validateExpression,
|
|
decodeDataSourceVariable,
|
|
computeExpressionsDependencies,
|
|
} from "@webstudio-is/react-sdk";
|
|
import {
|
|
propsStore,
|
|
stylesStore,
|
|
selectedInstanceSelectorStore,
|
|
styleSourceSelectionsStore,
|
|
styleSourcesStore,
|
|
instancesStore,
|
|
selectedPageStore,
|
|
breakpointsStore,
|
|
assetsStore,
|
|
projectStore,
|
|
registeredComponentMetasStore,
|
|
dataSourceValuesStore,
|
|
dataSourcesStore,
|
|
} from "../nano-states";
|
|
import {
|
|
type InstanceSelector,
|
|
insertInstancesCopyMutable,
|
|
insertPropsCopyMutable,
|
|
insertStylesCopyMutable,
|
|
insertStyleSourcesCopyMutable,
|
|
insertStyleSourceSelectionsCopyMutable,
|
|
findLocalStyleSourcesWithinInstances,
|
|
mergeNewBreakpointsMutable,
|
|
type DroppableTarget,
|
|
} from "../tree-utils";
|
|
import {
|
|
computeInstancesConstraints,
|
|
deleteInstance,
|
|
findClosestDroppableTarget,
|
|
isInstanceDetachable,
|
|
} from "../instance-utils";
|
|
import { getMapValuesBy, getMapValuesByKeysSet } from "../array-utils";
|
|
|
|
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 findTreeStyleSourceIds = (
|
|
styleSourceSelections: StyleSourceSelections,
|
|
treeInstanceIds: Set<Instance["id"]>
|
|
) => {
|
|
const treeStyleSourceIds = new Set<StyleSource["id"]>();
|
|
for (const { instanceId, values } of styleSourceSelections.values()) {
|
|
// skip selections outside of tree
|
|
if (treeInstanceIds.has(instanceId) === false) {
|
|
continue;
|
|
}
|
|
for (const styleSourceId of values) {
|
|
treeStyleSourceIds.add(styleSourceId);
|
|
}
|
|
}
|
|
return treeStyleSourceIds;
|
|
};
|
|
|
|
const getAssetsUsedInStyle = (
|
|
style: StyleDecl[],
|
|
foundAssetsIds = new Set<Asset["id"]>()
|
|
) => {
|
|
const fontFamilies = new Set<string>();
|
|
|
|
const processValues = (values: StyleDecl["value"][]) => {
|
|
for (const value of values) {
|
|
if (value.type === "fontFamily") {
|
|
for (const fontFamily of value.value) {
|
|
fontFamilies.add(fontFamily);
|
|
}
|
|
continue;
|
|
}
|
|
if (value.type === "image") {
|
|
if (value.value.type === "asset") {
|
|
foundAssetsIds.add(value.value.value);
|
|
}
|
|
continue;
|
|
}
|
|
if (value.type === "var") {
|
|
processValues(value.fallbacks);
|
|
continue;
|
|
}
|
|
if (value.type === "tuple" || value.type === "layers") {
|
|
processValues(value.value);
|
|
continue;
|
|
}
|
|
if (
|
|
value.type === "unit" ||
|
|
value.type === "keyword" ||
|
|
value.type === "unparsed" ||
|
|
value.type === "invalid" ||
|
|
value.type === "unset" ||
|
|
value.type === "rgb"
|
|
) {
|
|
continue;
|
|
}
|
|
value satisfies never;
|
|
}
|
|
};
|
|
|
|
processValues(style.map(({ value }) => value));
|
|
|
|
for (const asset of assetsStore.get().values()) {
|
|
if (asset?.type === "font" && fontFamilies.has(asset.meta.family)) {
|
|
foundAssetsIds.add(asset.id);
|
|
}
|
|
}
|
|
|
|
return foundAssetsIds;
|
|
};
|
|
|
|
const getAssetsUsedInProps = (props: Prop[], foundAssetsIds = new Set()) => {
|
|
for (const prop of props) {
|
|
if (prop.type === "asset") {
|
|
foundAssetsIds.add(prop.value);
|
|
continue;
|
|
}
|
|
if (
|
|
prop.type === "number" ||
|
|
prop.type === "string" ||
|
|
prop.type === "boolean" ||
|
|
prop.type === "page" ||
|
|
prop.type === "string[]" ||
|
|
prop.type === "dataSource" ||
|
|
prop.type === "action"
|
|
) {
|
|
continue;
|
|
}
|
|
prop satisfies never;
|
|
}
|
|
return foundAssetsIds;
|
|
};
|
|
|
|
const getPropTypeAndValue = (value: unknown) => {
|
|
if (typeof value === "boolean") {
|
|
return { type: "boolean", value } as const;
|
|
}
|
|
if (typeof value === "number") {
|
|
return { type: "number", value } as const;
|
|
}
|
|
if (typeof value === "string") {
|
|
return { type: "string", value } as const;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
return { type: "string[]", value } as const;
|
|
}
|
|
throw Error(`Unexpected prop value ${value}`);
|
|
};
|
|
|
|
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;
|
|
const instances = instancesStore.get();
|
|
const treeInstanceIds = findTreeInstanceIds(instances, targetInstanceId);
|
|
const styleSourceSelections = styleSourceSelectionsStore.get();
|
|
const treeStyleSourceIds = findTreeStyleSourceIds(
|
|
styleSourceSelections,
|
|
treeInstanceIds
|
|
);
|
|
|
|
// first item is guaranteed root of copied tree
|
|
const treeInstances = getMapValuesByKeysSet(instances, treeInstanceIds);
|
|
|
|
const dataSources = dataSourcesStore.get();
|
|
const expressions = new Map<string, string>();
|
|
for (const dataSource of dataSources.values()) {
|
|
if (dataSource.type === "expression") {
|
|
expressions.set(encodeDataSourceVariable(dataSource.id), dataSource.code);
|
|
}
|
|
}
|
|
const dependencies = computeExpressionsDependencies(expressions);
|
|
|
|
const treeDataSources = getMapValuesBy(dataSources, (dataSource) => {
|
|
if (dataSource.type === "expression") {
|
|
const expressionDeps = dependencies.get(
|
|
encodeDataSourceVariable(dataSource.id)
|
|
);
|
|
if (expressionDeps) {
|
|
for (const dependency of expressionDeps) {
|
|
const dataSourceId = decodeDataSourceVariable(dependency);
|
|
if (dataSourceId === undefined) {
|
|
continue;
|
|
}
|
|
const dataSource = dataSources.get(dataSourceId);
|
|
if (dataSource === undefined) {
|
|
continue;
|
|
}
|
|
if (
|
|
dataSource.scopeInstanceId === undefined ||
|
|
treeInstanceIds.has(dataSource.scopeInstanceId) === false
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
dataSource.scopeInstanceId !== undefined &&
|
|
treeInstanceIds.has(dataSource.scopeInstanceId)
|
|
);
|
|
});
|
|
const treeDataSourceIds = new Set(
|
|
treeDataSources.map((dataSource) => dataSource.id)
|
|
);
|
|
|
|
const dataSourceValues = dataSourceValuesStore.get();
|
|
const treeProps = getMapValuesBy(propsStore.get(), (prop) =>
|
|
treeInstanceIds.has(prop.instanceId)
|
|
).map((prop) => {
|
|
if (prop.type === "dataSource") {
|
|
const dataSourceId = prop.value;
|
|
// copy data source if scoped to one of copied instances
|
|
if (treeDataSourceIds.has(dataSourceId)) {
|
|
return prop;
|
|
}
|
|
// convert data source prop to typed prop
|
|
// when data source is not scoped to one of copied instances
|
|
const value = dataSourceValues.get(dataSourceId);
|
|
return {
|
|
id: prop.id,
|
|
instanceId: prop.instanceId,
|
|
name: prop.name,
|
|
...getPropTypeAndValue(value),
|
|
} satisfies Prop;
|
|
}
|
|
if (prop.type === "action") {
|
|
return {
|
|
...prop,
|
|
value: prop.value.flatMap((value) => {
|
|
if (value.type !== "execute") {
|
|
return [value];
|
|
}
|
|
let shouldKeepAction = true;
|
|
validateExpression(value.code, {
|
|
effectful: true,
|
|
transformIdentifier: (identifier) => {
|
|
if (value.args.includes(identifier)) {
|
|
return identifier;
|
|
}
|
|
const id = decodeDataSourceVariable(identifier);
|
|
if (id === undefined) {
|
|
return identifier;
|
|
}
|
|
if (treeDataSourceIds.has(id) === false) {
|
|
shouldKeepAction = false;
|
|
return identifier;
|
|
}
|
|
const identifierDeps = dependencies.get(id);
|
|
if (identifierDeps) {
|
|
for (const dependency of identifierDeps) {
|
|
const id = decodeDataSourceVariable(dependency);
|
|
if (id === undefined) {
|
|
continue;
|
|
}
|
|
if (treeDataSourceIds.has(id) === false) {
|
|
shouldKeepAction = false;
|
|
return identifier;
|
|
}
|
|
}
|
|
}
|
|
return identifier;
|
|
},
|
|
});
|
|
if (shouldKeepAction) {
|
|
return [value];
|
|
}
|
|
return [];
|
|
}),
|
|
};
|
|
}
|
|
return prop;
|
|
});
|
|
|
|
const treeStyleSourceSelections = getMapValuesByKeysSet(
|
|
styleSourceSelections,
|
|
treeInstanceIds
|
|
);
|
|
|
|
const treeStyleSources = getMapValuesByKeysSet(
|
|
styleSourcesStore.get(),
|
|
treeStyleSourceIds
|
|
);
|
|
|
|
const treeStyles = getMapValuesBy(stylesStore.get(), (styleDecl) =>
|
|
treeStyleSourceIds.has(styleDecl.styleSourceId)
|
|
);
|
|
|
|
const treeBreapointIds = new Set<Breakpoint["id"]>();
|
|
for (const styleDecl of treeStyles) {
|
|
treeBreapointIds.add(styleDecl.breakpointId);
|
|
}
|
|
const treeBreapoints = getMapValuesByKeysSet(
|
|
breakpointsStore.get(),
|
|
treeBreapointIds
|
|
);
|
|
|
|
const treeAssets = getMapValuesByKeysSet(
|
|
assetsStore.get(),
|
|
getAssetsUsedInProps(treeProps, getAssetsUsedInStyle(treeStyles))
|
|
);
|
|
|
|
return {
|
|
instanceSelector: targetInstanceSelector,
|
|
breakpoints: treeBreapoints,
|
|
instances: treeInstances,
|
|
styleSources: treeStyleSources,
|
|
dataSources: treeDataSources,
|
|
props: treeProps,
|
|
styleSourceSelections: treeStyleSourceSelections,
|
|
styles: treeStyles,
|
|
assets: treeAssets,
|
|
};
|
|
};
|
|
|
|
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 onPaste = (clipboardData: string): boolean => {
|
|
const data = parse(clipboardData);
|
|
|
|
const selectedPage = selectedPageStore.get();
|
|
const project = projectStore.get();
|
|
|
|
if (
|
|
data === undefined ||
|
|
selectedPage === undefined ||
|
|
project === undefined
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const metas = registeredComponentMetasStore.get();
|
|
const newInstances = new Map(
|
|
data.instances.map((instance) => [instance.id, instance])
|
|
);
|
|
const rootInstanceId = data.instances[0].id;
|
|
// paste to the root if nothing is selected
|
|
const instanceSelector = selectedInstanceSelectorStore.get() ?? [
|
|
selectedPage.rootInstanceId,
|
|
];
|
|
let potentialDropTarget: undefined | DroppableTarget;
|
|
if (shallowEqual(instanceSelector, data.instanceSelector)) {
|
|
// paste after selected instance
|
|
const instances = instancesStore.get();
|
|
// 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 false;
|
|
}
|
|
const indexWithinChildren = parentInstance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === currentInstanceId
|
|
);
|
|
potentialDropTarget = {
|
|
parentSelector: instanceSelector.slice(1),
|
|
position: indexWithinChildren + 1,
|
|
};
|
|
} else {
|
|
potentialDropTarget = findClosestDroppableTarget(
|
|
metas,
|
|
instancesStore.get(),
|
|
instanceSelector,
|
|
computeInstancesConstraints(metas, newInstances, [rootInstanceId])
|
|
);
|
|
}
|
|
if (potentialDropTarget === undefined) {
|
|
return false;
|
|
}
|
|
const dropTarget = potentialDropTarget;
|
|
|
|
store.createTransaction(
|
|
[
|
|
breakpointsStore,
|
|
instancesStore,
|
|
dataSourcesStore,
|
|
styleSourcesStore,
|
|
propsStore,
|
|
styleSourceSelectionsStore,
|
|
stylesStore,
|
|
assetsStore,
|
|
],
|
|
(
|
|
breakpoints,
|
|
instances,
|
|
dataSources,
|
|
styleSources,
|
|
props,
|
|
styleSourceSelections,
|
|
styles,
|
|
assets
|
|
) => {
|
|
for (const asset of data.assets) {
|
|
// asset can be already present if pasting to the same project
|
|
if (assets.has(asset.id) === false) {
|
|
// we use the same asset.id so the references are preserved
|
|
assets.set(asset.id, { ...asset, projectId: project.id });
|
|
}
|
|
}
|
|
|
|
const mergedBreakpointIds = mergeNewBreakpointsMutable(
|
|
breakpoints,
|
|
data.breakpoints
|
|
);
|
|
|
|
const copiedInstanceIds = insertInstancesCopyMutable(
|
|
instances,
|
|
props,
|
|
registeredComponentMetasStore.get(),
|
|
data.instances,
|
|
dropTarget
|
|
);
|
|
|
|
const copiedDataSourceIds = new Map<DataSource["id"], DataSource["id"]>();
|
|
for (const dataSource of data.dataSources) {
|
|
copiedDataSourceIds.set(dataSource.id, nanoid());
|
|
}
|
|
|
|
for (const dataSource of data.dataSources) {
|
|
let { scopeInstanceId } = dataSource;
|
|
if (scopeInstanceId !== undefined) {
|
|
scopeInstanceId = copiedInstanceIds.get(scopeInstanceId);
|
|
}
|
|
const newId = copiedDataSourceIds.get(dataSource.id);
|
|
if (newId === undefined) {
|
|
continue;
|
|
}
|
|
if (dataSource.type === "variable") {
|
|
dataSources.set(newId, {
|
|
...dataSource,
|
|
id: newId,
|
|
scopeInstanceId,
|
|
});
|
|
}
|
|
if (dataSource.type === "expression") {
|
|
dataSources.set(newId, {
|
|
...dataSource,
|
|
id: newId,
|
|
scopeInstanceId,
|
|
code: validateExpression(dataSource.code, {
|
|
transformIdentifier: (id) => {
|
|
const dataSourceId = decodeDataSourceVariable(id);
|
|
if (dataSourceId === undefined) {
|
|
return id;
|
|
}
|
|
const newId = copiedDataSourceIds.get(dataSourceId);
|
|
if (newId === undefined) {
|
|
return id;
|
|
}
|
|
return encodeDataSourceVariable(newId);
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
}
|
|
|
|
const localStyleSourceIds = findLocalStyleSourcesWithinInstances(
|
|
data.styleSources,
|
|
data.styleSourceSelections,
|
|
new Set(copiedInstanceIds.keys())
|
|
);
|
|
|
|
const copiedStyleSourceIds = insertStyleSourcesCopyMutable(
|
|
styleSources,
|
|
data.styleSources,
|
|
localStyleSourceIds
|
|
);
|
|
|
|
insertPropsCopyMutable(
|
|
props,
|
|
data.props.map((prop) => {
|
|
if (prop.type === "action") {
|
|
return {
|
|
...prop,
|
|
value: prop.value.map((value) => {
|
|
if (value.type !== "execute") {
|
|
return value;
|
|
}
|
|
return {
|
|
...value,
|
|
code: validateExpression(value.code, {
|
|
effectful: true,
|
|
transformIdentifier: (id) => {
|
|
const dataSourceId = decodeDataSourceVariable(id);
|
|
if (dataSourceId === undefined) {
|
|
return id;
|
|
}
|
|
const newId = copiedDataSourceIds.get(dataSourceId);
|
|
if (newId === undefined) {
|
|
return id;
|
|
}
|
|
return encodeDataSourceVariable(newId);
|
|
},
|
|
}),
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
return prop;
|
|
}),
|
|
copiedInstanceIds,
|
|
copiedDataSourceIds
|
|
);
|
|
insertStyleSourceSelectionsCopyMutable(
|
|
styleSourceSelections,
|
|
data.styleSourceSelections,
|
|
copiedInstanceIds,
|
|
copiedStyleSourceIds
|
|
);
|
|
insertStylesCopyMutable(
|
|
styles,
|
|
data.styles,
|
|
copiedStyleSourceIds,
|
|
mergedBreakpointIds
|
|
);
|
|
}
|
|
);
|
|
return true;
|
|
};
|
|
|
|
export const onCopy = () => {
|
|
const selectedInstanceSelector = selectedInstanceSelectorStore.get();
|
|
if (selectedInstanceSelector === undefined) {
|
|
return;
|
|
}
|
|
const data = getTreeData(selectedInstanceSelector);
|
|
if (data === undefined) {
|
|
return;
|
|
}
|
|
return stringify(data);
|
|
};
|
|
|
|
export const onCut = () => {
|
|
const selectedInstanceSelector = selectedInstanceSelectorStore.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);
|
|
};
|