Files
webstudio/apps/builder/app/shared/copy-paste/plugin-instance.ts
T
Bogdan Chadkin dcae1b5a9d Allow inserting into editable containers (#1735)
Here I removed "rich-text" type from components. Instead "container" can
be edited when has only text or rich text children.

And any time user can drop into editablle container which will wrap all
text and rich text children into "Span" component.

Had to implement position adjustment to consider text children which do
not have representation in navigator tree and canvas dnd.

Span has temporary SpanContainer name to not conflict with existinig
Span for styled text in rich text. Will rename in other PR when remove
no longer necessary components.
2023-06-10 00:55:46 +03:00

360 lines
9.1 KiB
TypeScript

import store from "immerhin";
import { z } from "zod";
import {
Breakpoint,
findTreeInstanceIds,
Instance,
Prop,
StyleDecl,
StyleSource,
StyleSourceSelection,
StyleSourceSelections,
} from "@webstudio-is/project-build";
import {
propsStore,
stylesStore,
selectedInstanceSelectorStore,
styleSourceSelectionsStore,
styleSourcesStore,
instancesStore,
selectedPageStore,
breakpointsStore,
selectedStyleSourceSelectorStore,
assetsStore,
projectStore,
registeredComponentMetasStore,
} from "../nano-states";
import {
type InstanceSelector,
insertInstancesCopyMutable,
insertPropsCopyMutable,
insertStylesCopyMutable,
insertStyleSourcesCopyMutable,
insertStyleSourceSelectionsCopyMutable,
findLocalStyleSourcesWithinInstances,
mergeNewBreakpointsMutable,
} from "../tree-utils";
import { deleteInstance, findClosestDroppableTarget } from "../instance-utils";
import { getMapValuesBy, getMapValuesByKeysSet } from "../array-utils";
import { Asset } from "@webstudio-is/asset-uploader";
const version = "@webstudio/instance/v0.1";
const InstanceData = z.object({
breakpoints: z.array(Breakpoint),
instances: z.array(Instance),
props: z.array(Prop),
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[]"
) {
continue;
}
prop satisfies never;
}
return foundAssetsIds;
};
const getTreeData = (targetInstanceSelector: InstanceSelector) => {
// @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 treeProps = getMapValuesBy(propsStore.get(), (prop) =>
treeInstanceIds.has(prop.instanceId)
);
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 {
breakpoints: treeBreapoints,
instances: treeInstances,
styleSources: treeStyleSources,
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) => {
const data = parse(clipboardData);
const selectedPage = selectedPageStore.get();
const project = projectStore.get();
if (
data === undefined ||
selectedPage === undefined ||
project === undefined
) {
return;
}
// paste to the root if nothing is selected
const instanceSelector = selectedInstanceSelectorStore.get() ?? [
selectedPage.rootInstanceId,
];
const dragComponent = data.instances[0].component;
const dropTarget = findClosestDroppableTarget(
registeredComponentMetasStore.get(),
instancesStore.get(),
instanceSelector,
[dragComponent]
);
if (dropTarget === undefined) {
return;
}
store.createTransaction(
[
breakpointsStore,
instancesStore,
styleSourcesStore,
propsStore,
styleSourceSelectionsStore,
stylesStore,
assetsStore,
],
(
breakpoints,
instances,
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 localStyleSourceIds = findLocalStyleSourcesWithinInstances(
data.styleSources,
data.styleSourceSelections,
new Set(copiedInstanceIds.keys())
);
const copiedStyleSourceIds = insertStyleSourcesCopyMutable(
styleSources,
data.styleSources,
localStyleSourceIds
);
insertPropsCopyMutable(props, data.props, copiedInstanceIds);
insertStyleSourceSelectionsCopyMutable(
styleSourceSelections,
data.styleSourceSelections,
copiedInstanceIds,
copiedStyleSourceIds
);
insertStylesCopyMutable(
styles,
data.styles,
copiedStyleSourceIds,
mergedBreakpointIds
);
// first item is guaranteed root of copied tree
const copiedRootInstanceId = Array.from(copiedInstanceIds.values())[0];
selectedInstanceSelectorStore.set([
copiedRootInstanceId,
...instanceSelector,
]);
selectedStyleSourceSelectorStore.set(undefined);
}
);
};
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);
};