mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
Ref https://github.com/webstudio-is/webstudio/issues/2532 We successfullly migrated prod to expression prop. Now we can safely remove legacy data source prop handling across codebase.
375 lines
10 KiB
TypeScript
375 lines
10 KiB
TypeScript
import { shallowEqual } from "shallow-equal";
|
|
import { nanoid } from "nanoid";
|
|
import { z } from "zod";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
Asset,
|
|
Breakpoint,
|
|
DataSource,
|
|
Instance,
|
|
Prop,
|
|
StyleDecl,
|
|
StyleSource,
|
|
StyleSourceSelection,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
encodeDataSourceVariable,
|
|
validateExpression,
|
|
decodeDataSourceVariable,
|
|
} from "@webstudio-is/react-sdk";
|
|
import {
|
|
propsStore,
|
|
stylesStore,
|
|
selectedInstanceSelectorStore,
|
|
styleSourceSelectionsStore,
|
|
styleSourcesStore,
|
|
instancesStore,
|
|
selectedPageStore,
|
|
breakpointsStore,
|
|
assetsStore,
|
|
projectStore,
|
|
registeredComponentMetasStore,
|
|
dataSourcesStore,
|
|
} from "../nano-states";
|
|
import {
|
|
type InstanceSelector,
|
|
insertInstancesCopyMutable,
|
|
insertPropsCopyMutable,
|
|
insertStylesCopyMutable,
|
|
insertStyleSourcesCopyMutable,
|
|
insertStyleSourceSelectionsCopyMutable,
|
|
findLocalStyleSourcesWithinInstances,
|
|
mergeNewBreakpointsMutable,
|
|
type DroppableTarget,
|
|
} from "../tree-utils";
|
|
import {
|
|
computeInstancesConstraints,
|
|
deleteInstance,
|
|
findClosestDroppableTarget,
|
|
getInstancesSlice,
|
|
isInstanceDetachable,
|
|
} from "../instance-utils";
|
|
import { serverSyncStore } from "../sync";
|
|
|
|
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 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;
|
|
|
|
return {
|
|
instanceSelector: targetInstanceSelector,
|
|
...getInstancesSlice(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;
|
|
}
|
|
};
|
|
|
|
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;
|
|
|
|
serverSyncStore.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
|
|
);
|
|
|
|
// duplicate data sources only within copied tree
|
|
const copiedDataSourceIds = new Map<DataSource["id"], DataSource["id"]>();
|
|
const copiedDataSources = new Map<DataSource["id"], DataSource>();
|
|
for (const dataSource of data.dataSources) {
|
|
const { scopeInstanceId } = dataSource;
|
|
if (
|
|
scopeInstanceId !== undefined &&
|
|
copiedInstanceIds.has(scopeInstanceId)
|
|
) {
|
|
copiedDataSourceIds.set(dataSource.id, nanoid());
|
|
}
|
|
copiedDataSources.set(dataSource.id, dataSource);
|
|
}
|
|
|
|
for (const dataSource of data.dataSources) {
|
|
let { scopeInstanceId } = dataSource;
|
|
if (scopeInstanceId === undefined) {
|
|
continue;
|
|
}
|
|
// reject data sources outside of copied tree and not scoped to ancestors
|
|
if (
|
|
copiedInstanceIds.has(scopeInstanceId) === false &&
|
|
instanceSelector.includes(scopeInstanceId) === false
|
|
) {
|
|
continue;
|
|
}
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
const localStyleSourceIds = findLocalStyleSourcesWithinInstances(
|
|
data.styleSources,
|
|
data.styleSourceSelections,
|
|
new Set(copiedInstanceIds.keys())
|
|
);
|
|
|
|
const copiedStyleSourceIds = insertStyleSourcesCopyMutable(
|
|
styleSources,
|
|
data.styleSources,
|
|
localStyleSourceIds
|
|
);
|
|
|
|
insertPropsCopyMutable(
|
|
props,
|
|
data.props.map((prop) => {
|
|
let actionDiscarded = false;
|
|
const transformIdentifier = (
|
|
identifier: string,
|
|
assignee: boolean
|
|
) => {
|
|
const dataSourceId = decodeDataSourceVariable(identifier);
|
|
if (dataSourceId === undefined) {
|
|
return identifier;
|
|
}
|
|
// data source is within copied tree
|
|
const newId = copiedDataSourceIds.get(dataSourceId);
|
|
if (newId !== undefined) {
|
|
return encodeDataSourceVariable(newId);
|
|
}
|
|
const dataSource = copiedDataSources.get(dataSourceId);
|
|
// data source is within paste target
|
|
if (
|
|
dataSource?.scopeInstanceId !== undefined &&
|
|
instanceSelector.includes(dataSource.scopeInstanceId)
|
|
) {
|
|
return identifier;
|
|
}
|
|
// left operand of assign operator cannot be inlined
|
|
if (assignee) {
|
|
actionDiscarded = true;
|
|
}
|
|
// inline variable not scoped to copied tree or paste target
|
|
if (dataSource?.type === "variable") {
|
|
return JSON.stringify(dataSource.value.value);
|
|
}
|
|
return identifier;
|
|
};
|
|
|
|
if (prop.type === "expression") {
|
|
return {
|
|
...prop,
|
|
value: validateExpression(prop.value, {
|
|
transformIdentifier,
|
|
}),
|
|
};
|
|
}
|
|
|
|
if (prop.type === "action") {
|
|
return {
|
|
...prop,
|
|
value: prop.value.flatMap((value) => {
|
|
if (value.type !== "execute") {
|
|
return [value];
|
|
}
|
|
actionDiscarded = false;
|
|
const newCode = validateExpression(value.code, {
|
|
effectful: true,
|
|
transformIdentifier,
|
|
});
|
|
if (actionDiscarded) {
|
|
return [];
|
|
}
|
|
return [{ ...value, code: newCode }];
|
|
}),
|
|
};
|
|
}
|
|
|
|
return prop;
|
|
}),
|
|
copiedInstanceIds
|
|
);
|
|
|
|
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);
|
|
};
|