mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 22:14:29 +09:00
364 lines
11 KiB
TypeScript
364 lines
11 KiB
TypeScript
// Insert utilities own deciding where newly created or pasted instance content
|
|
// should be placed. Put insert target resolution and insertion commands here,
|
|
// while fragment cloning stays in fragment.ts and existing-instance moves stay
|
|
// in mutation.ts.
|
|
import { nanoid } from "nanoid";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
type Instance,
|
|
type WebstudioData,
|
|
type WebstudioFragment,
|
|
ROOT_INSTANCE_ID,
|
|
collectionComponent,
|
|
elementComponent,
|
|
tags,
|
|
} from "@webstudio-is/sdk";
|
|
import type { ConflictResolution } from "../token-conflict-dialog";
|
|
import {
|
|
$registeredComponentMetas,
|
|
$registeredTemplates,
|
|
$selectedInstancePath,
|
|
$selectedInstanceSelector,
|
|
$selectedPage,
|
|
getInstancePath,
|
|
selectInstance,
|
|
} from "../nano-states";
|
|
import { $instances, $project, $props } from "../sync/data-stores";
|
|
import { findClosestInstanceMatchingFragment } from "../matcher";
|
|
import {
|
|
findClosestNonTextualContainer,
|
|
isTreeSatisfyingContentModel,
|
|
} from "../content-model";
|
|
import { findAvailableVariables } from "@webstudio-is/project-build/runtime/data";
|
|
import { getInstanceLabel } from "~/builder/shared/instance-label";
|
|
import { getSlotFragmentDropTargetMutable } from "./slot";
|
|
import {
|
|
type DroppableTarget,
|
|
type InstanceSelector,
|
|
wrapEditableChildrenAroundDropTargetMutable,
|
|
} from "./tree";
|
|
import { updateWebstudioData } from "./data";
|
|
import { insertWebstudioFragmentCopy } from "@webstudio-is/project-build/runtime/fragment";
|
|
|
|
const getExistingInsertTargetPath = (insertable: Insertable) => {
|
|
const instancePath = getInstancePath(
|
|
insertable.parentSelector,
|
|
$instances.get()
|
|
);
|
|
if (instancePath === undefined) {
|
|
toast.error("Cannot insert: the target no longer exists.");
|
|
return;
|
|
}
|
|
return instancePath;
|
|
};
|
|
|
|
export const insertInstanceChildrenMutable = (
|
|
data: Omit<WebstudioData, "pages">,
|
|
children: Instance["children"],
|
|
insertTarget: Insertable
|
|
) => {
|
|
const dropTarget: DroppableTarget = {
|
|
parentSelector: insertTarget.parentSelector,
|
|
position: insertTarget.position === "after" ? "end" : insertTarget.position,
|
|
};
|
|
const metas = $registeredComponentMetas.get();
|
|
let normalizedDropTarget =
|
|
getSlotFragmentDropTargetMutable(data.instances, dropTarget) ?? dropTarget;
|
|
normalizedDropTarget =
|
|
wrapEditableChildrenAroundDropTargetMutable(
|
|
data.instances,
|
|
data.props,
|
|
metas,
|
|
normalizedDropTarget
|
|
) ?? normalizedDropTarget;
|
|
const [parentInstanceId] = normalizedDropTarget.parentSelector;
|
|
const parentInstance = data.instances.get(parentInstanceId);
|
|
if (parentInstance === undefined) {
|
|
return;
|
|
}
|
|
if (normalizedDropTarget.position === "end") {
|
|
parentInstance.children.push(...children);
|
|
} else {
|
|
parentInstance.children.splice(
|
|
normalizedDropTarget.position,
|
|
0,
|
|
...children
|
|
);
|
|
}
|
|
};
|
|
|
|
export const insertWebstudioElementAt = (insertable?: Insertable) => {
|
|
const instances = $instances.get();
|
|
const props = $props.get();
|
|
const metas = $registeredComponentMetas.get();
|
|
// find closest container and try to match new element with it
|
|
if (insertable === undefined) {
|
|
const instancePath = $selectedInstancePath.get();
|
|
if (instancePath === undefined) {
|
|
return false;
|
|
}
|
|
const [{ instanceSelector }] = instancePath;
|
|
const containerSelector = findClosestNonTextualContainer({
|
|
instances,
|
|
props,
|
|
metas,
|
|
instanceSelector,
|
|
});
|
|
const insertableIndex = instanceSelector.length - containerSelector.length;
|
|
if (insertableIndex === 0) {
|
|
insertable = {
|
|
parentSelector: containerSelector,
|
|
position: "end",
|
|
};
|
|
} else {
|
|
const containerInstance = instances.get(containerSelector[0]);
|
|
if (containerInstance === undefined) {
|
|
return false;
|
|
}
|
|
const lastChildInstanceId = instanceSelector[insertableIndex - 1];
|
|
const lastChildPosition = containerInstance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === lastChildInstanceId
|
|
);
|
|
insertable = {
|
|
parentSelector: containerSelector,
|
|
position: lastChildPosition + 1,
|
|
};
|
|
}
|
|
}
|
|
if (getExistingInsertTargetPath(insertable) === undefined) {
|
|
return false;
|
|
}
|
|
// create element and find matching tag
|
|
const element: Instance = {
|
|
type: "instance",
|
|
id: nanoid(),
|
|
component: elementComponent,
|
|
children: [],
|
|
};
|
|
const newInstances = new Map(instances);
|
|
newInstances.set(element.id, element);
|
|
let matchingTag: undefined | string;
|
|
for (const tag of tags) {
|
|
element.tag = tag;
|
|
const isSatisfying = isTreeSatisfyingContentModel({
|
|
instances: newInstances,
|
|
props,
|
|
metas,
|
|
instanceSelector: [element.id, ...insertable.parentSelector],
|
|
});
|
|
if (isSatisfying) {
|
|
matchingTag = tag;
|
|
break;
|
|
}
|
|
}
|
|
if (matchingTag === undefined) {
|
|
return false;
|
|
}
|
|
// insert element
|
|
updateWebstudioData((data) => {
|
|
data.instances.set(element.id, element);
|
|
const children: Instance["children"] = [{ type: "id", value: element.id }];
|
|
insertInstanceChildrenMutable(data, children, insertable);
|
|
});
|
|
selectInstance([element.id, ...insertable.parentSelector]);
|
|
return true;
|
|
};
|
|
|
|
export const insertWebstudioFragmentAt = (
|
|
fragment: WebstudioFragment,
|
|
insertable?: Insertable,
|
|
conflictResolution?: ConflictResolution,
|
|
options?: { onBreakpointLimitMerge?: () => void }
|
|
): boolean => {
|
|
const hasChildren = fragment.children.length > 0;
|
|
const hasTokens = fragment.styleSources.length > 0;
|
|
if (!hasChildren && !hasTokens) {
|
|
return false;
|
|
}
|
|
// Tokens-only fragment: insert tokens/breakpoints/styles without instances
|
|
if (!hasChildren && hasTokens) {
|
|
const project = $project.get();
|
|
if (project === undefined) {
|
|
return false;
|
|
}
|
|
updateWebstudioData((data) => {
|
|
insertWebstudioFragmentCopy({
|
|
data,
|
|
fragment,
|
|
availableVariables: [],
|
|
projectId: project.id,
|
|
conflictResolution,
|
|
onBreakpointLimitMerge: options?.onBreakpointLimitMerge,
|
|
});
|
|
});
|
|
return true;
|
|
}
|
|
const project = $project.get();
|
|
insertable = findClosestInsertable(fragment, insertable) ?? insertable;
|
|
if (project === undefined || insertable === undefined) {
|
|
return false;
|
|
}
|
|
const initialInstancePath = getExistingInsertTargetPath(insertable);
|
|
if (initialInstancePath === undefined) {
|
|
return false;
|
|
}
|
|
let newInstanceSelector: undefined | InstanceSelector;
|
|
updateWebstudioData((data) => {
|
|
const instancePath =
|
|
getInstancePath(insertable.parentSelector, data.instances) ??
|
|
initialInstancePath;
|
|
const { newInstanceIds } = insertWebstudioFragmentCopy({
|
|
data,
|
|
fragment,
|
|
availableVariables: findAvailableVariables({
|
|
...data,
|
|
startingInstanceId: instancePath[0].instance.id,
|
|
}),
|
|
projectId: project.id,
|
|
conflictResolution,
|
|
onBreakpointLimitMerge: options?.onBreakpointLimitMerge,
|
|
});
|
|
const children: Instance["children"] = fragment.children.map((child) => {
|
|
if (child.type === "id") {
|
|
return {
|
|
type: "id",
|
|
value: newInstanceIds.get(child.value) ?? child.value,
|
|
};
|
|
}
|
|
return child;
|
|
});
|
|
let parentSelector;
|
|
let position: number | "end";
|
|
if (insertable.position === "after") {
|
|
if (instancePath.length === 1) {
|
|
parentSelector = insertable.parentSelector;
|
|
position = "end";
|
|
} else {
|
|
parentSelector = instancePath[1].instanceSelector;
|
|
const [{ instance }, { instance: parentInstance }] = instancePath;
|
|
const index = parentInstance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === instance.id
|
|
);
|
|
position = 1 + index;
|
|
}
|
|
} else {
|
|
parentSelector = insertable.parentSelector;
|
|
position = insertable.position;
|
|
}
|
|
insertInstanceChildrenMutable(data, children, {
|
|
parentSelector,
|
|
position,
|
|
});
|
|
newInstanceSelector = [children[0].value, ...parentSelector];
|
|
});
|
|
if (newInstanceSelector) {
|
|
selectInstance(newInstanceSelector);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
export const getComponentTemplateData = (
|
|
componentOrTemplate: string
|
|
): WebstudioFragment => {
|
|
const templates = $registeredTemplates.get();
|
|
const templateMeta = templates.get(componentOrTemplate);
|
|
if (templateMeta) {
|
|
return templateMeta.template;
|
|
}
|
|
const newInstance: Instance = {
|
|
id: nanoid(),
|
|
type: "instance",
|
|
component: componentOrTemplate,
|
|
children: [],
|
|
};
|
|
return {
|
|
children: [{ type: "id", value: newInstance.id }],
|
|
instances: [newInstance],
|
|
props: [],
|
|
dataSources: [],
|
|
styleSourceSelections: [],
|
|
styleSources: [],
|
|
styles: [],
|
|
breakpoints: [],
|
|
assets: [],
|
|
resources: [],
|
|
};
|
|
};
|
|
|
|
export type Insertable = {
|
|
parentSelector: InstanceSelector;
|
|
position: number | "end" | "after";
|
|
};
|
|
|
|
export const findClosestInsertable = (
|
|
fragment: WebstudioFragment,
|
|
from?: Insertable
|
|
): undefined | Insertable => {
|
|
const selectedPage = $selectedPage.get();
|
|
if (selectedPage === undefined) {
|
|
return;
|
|
}
|
|
// paste to the page root if nothing is selected
|
|
const instanceSelector = from?.parentSelector ??
|
|
$selectedInstanceSelector.get() ?? [selectedPage.rootInstanceId];
|
|
if (instanceSelector[0] === ROOT_INSTANCE_ID) {
|
|
toast.error(`Cannot insert into Global root`);
|
|
return;
|
|
}
|
|
const metas = $registeredComponentMetas.get();
|
|
const instances = $instances.get();
|
|
const props = $props.get();
|
|
const containerSelector = findClosestNonTextualContainer({
|
|
metas,
|
|
props,
|
|
instances,
|
|
instanceSelector,
|
|
});
|
|
const closestContainerIndex =
|
|
instanceSelector.length - containerSelector.length;
|
|
if (closestContainerIndex === -1) {
|
|
return;
|
|
}
|
|
let insertableIndex = findClosestInstanceMatchingFragment({
|
|
metas,
|
|
instances,
|
|
props,
|
|
instanceSelector: instanceSelector.slice(closestContainerIndex),
|
|
fragment,
|
|
onError: (message) => {
|
|
const component = fragment.instances[0].component;
|
|
const label = getInstanceLabel({ component });
|
|
toast.warn(message || `"${label}" has no place here`);
|
|
},
|
|
});
|
|
if (insertableIndex === -1) {
|
|
return;
|
|
}
|
|
|
|
// adjust with container lookup
|
|
insertableIndex = insertableIndex + closestContainerIndex;
|
|
const parentSelector = instanceSelector.slice(insertableIndex);
|
|
if (insertableIndex === 0) {
|
|
return from ?? { parentSelector, position: "end" };
|
|
}
|
|
const instance = instances.get(instanceSelector[insertableIndex]);
|
|
if (instance === undefined) {
|
|
return;
|
|
}
|
|
// skip collection item when inserting something and go straight into collection instance
|
|
if (instance?.component === collectionComponent && insertableIndex === 1) {
|
|
return {
|
|
parentSelector,
|
|
position: "end",
|
|
};
|
|
}
|
|
const lastChildInstanceId = instanceSelector[insertableIndex - 1];
|
|
const lastChildPosition = instance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === lastChildInstanceId
|
|
);
|
|
return {
|
|
parentSelector,
|
|
position: lastChildPosition + 1,
|
|
};
|
|
};
|