Files
Oleg Isonen 04188dbbf4 feat: CSS Grid UI (#5614)
## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
2026-03-08 10:52:21 +01:00

1873 lines
53 KiB
TypeScript

import { current, isDraft } from "immer";
import { nanoid } from "nanoid";
import { toast } from "@webstudio-is/design-system";
import { builderApi } from "~/shared/builder-api";
import type { StyleValue } from "@webstudio-is/css-engine";
import { showAttribute } from "@webstudio-is/react-sdk";
import {
type Instances,
type Instance,
type StyleDecl,
type Asset,
type Breakpoints,
type DataSources,
type DataSource,
type WebstudioFragment,
type WebstudioData,
type Resource,
type WsComponentMeta,
type Pages,
getStyleDeclKey,
findTreeInstanceIds,
findTreeInstanceIdsExcludingSlotDescendants,
decodeDataSourceVariable,
encodeDataSourceVariable,
transpileExpression,
ROOT_INSTANCE_ID,
portalComponent,
collectionComponent,
Prop,
Props,
elementComponent,
tags,
blockTemplateComponent,
isComponentDetachable,
} from "@webstudio-is/sdk";
import { detectTokenConflicts } from "./style-source-utils";
import { type ConflictResolution } from "./token-conflict-dialog";
import { buildMergedBreakpointIds } from "./breakpoints-utils";
import {
$props,
$styles,
$styleSourceSelections,
$styleSources,
$instances,
$registeredComponentMetas,
$dataSources,
$assets,
$breakpoints,
$pages,
$resources,
$registeredTemplates,
$project,
$isPreviewMode,
$textEditingInstanceSelector,
$isContentMode,
findBlockSelector,
} from "./nano-states";
import {
type DroppableTarget,
type InstanceSelector,
findLocalStyleSourcesWithinInstances,
getReparentDropTargetMutable,
getInstanceOrCreateFragmentIfNecessary,
wrapEditableChildrenAroundDropTargetMutable,
} from "./tree-utils";
import {
insertStyleSources,
insertPortalLocalStyleSources,
insertLocalStyleSourcesWithNewIds,
deleteLocalStyleSourcesMutable,
collectStyleSourcesFromInstances,
} from "./style-source-utils";
import { removeByMutable } from "./array-utils";
import { serverSyncStore } from "./sync/sync-stores";
import { setDifference, setUnion } from "./shim";
import { breakCyclesMutable, findCycles } from "@webstudio-is/project-build";
import {
$awareness,
$selectedInstancePath,
$selectedPage,
findAwarenessByInstanceId,
getInstancePath,
selectInstance,
type InstancePath,
} from "./awareness";
import { findClosestInstanceMatchingFragment } from "./matcher";
import {
findAvailableVariables,
restoreExpressionVariables,
unsetExpressionVariables,
} from "./data-variables";
import {
findClosestNonTextualContainer,
isRichTextTree,
isTreeSatisfyingContentModel,
isRichTextContent,
} from "./content-model";
import type { Project } from "@webstudio-is/project";
import { getInstanceLabel } from "~/builder/shared/instance-label";
import { $instanceTags } from "~/builder/features/style-panel/shared/model";
import { reactPropsToStandardAttributes } from "@webstudio-is/react-sdk";
/**
* structuredClone can be invoked on draft and throw error
* extract current snapshot before cloning
*/
export const unwrap = <Value>(value: Value) =>
isDraft(value) ? current(value) : value;
export const updateWebstudioData = (mutate: (data: WebstudioData) => void) => {
serverSyncStore.createTransaction(
[
$pages,
$instances,
$props,
$breakpoints,
$styleSourceSelections,
$styleSources,
$styles,
$dataSources,
$resources,
$assets,
],
(
pages,
instances,
props,
breakpoints,
styleSourceSelections,
styleSources,
styles,
dataSources,
resources,
assets
) => {
// @todo normalize pages
if (pages === undefined) {
return;
}
mutate({
pages,
instances,
props,
dataSources,
resources,
breakpoints,
styleSourceSelections,
styleSources,
styles,
assets,
});
const cycles = findCycles(instances.values());
// Detect and fix cycles in the instance tree, then report
if (cycles.length > 0) {
toast.info("Detected and fixed cycles in the instance tree.");
breakCyclesMutable(
instances.values(),
(node) => node.component === "Slot"
);
}
}
);
};
export const getWebstudioData = (): WebstudioData => {
const pages = $pages.get();
if (pages === undefined) {
throw Error(`Cannot get webstudio data with empty pages`);
}
return {
pages,
instances: $instances.get(),
props: $props.get(),
dataSources: $dataSources.get(),
resources: $resources.get(),
breakpoints: $breakpoints.get(),
styleSourceSelections: $styleSourceSelections.get(),
styleSources: $styleSources.get(),
styles: $styles.get(),
assets: $assets.get(),
};
};
export const findAllEditableInstanceSelector = ({
instanceSelector,
instances,
props,
metas,
results,
}: {
instanceSelector: InstanceSelector;
instances: Instances;
props: Props;
metas: Map<string, WsComponentMeta>;
results: InstanceSelector[];
}) => {
const [instanceId] = instanceSelector;
if (instanceId === undefined) {
return;
}
// Check if current instance is text editing instance
if (isRichTextTree({ instanceId, instances, props, metas })) {
results.push(instanceSelector);
return;
}
const instance = instances.get(instanceId);
if (instance) {
for (const child of instance.children) {
if (child.type === "id") {
findAllEditableInstanceSelector({
instanceSelector: [child.value, ...instanceSelector],
instances,
props,
metas,
results,
});
}
}
}
};
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();
insertTarget =
getInstanceOrCreateFragmentIfNecessary(data.instances, dropTarget) ??
insertTarget;
insertTarget =
wrapEditableChildrenAroundDropTargetMutable(
data.instances,
data.props,
metas,
dropTarget
) ?? insertTarget;
const [parentInstanceId] = insertTarget.parentSelector;
const parentInstance = data.instances.get(parentInstanceId);
if (parentInstance === undefined) {
return;
}
if (dropTarget.position === "end") {
parentInstance.children.push(...children);
} else {
parentInstance.children.splice(dropTarget.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,
};
}
}
// 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
): boolean => {
// cannot insert empty fragment
if (fragment.children.length === 0) {
return false;
}
const project = $project.get();
insertable = findClosestInsertable(fragment, insertable) ?? insertable;
if (project === undefined || insertable === undefined) {
return false;
}
let newInstanceSelector: undefined | InstanceSelector;
updateWebstudioData((data) => {
const instancePath = getInstancePath(
insertable.parentSelector,
data.instances
);
if (instancePath === undefined) {
return;
}
const { newInstanceIds } = insertWebstudioFragmentCopy({
data,
fragment,
availableVariables: findAvailableVariables({
...data,
startingInstanceId: instancePath[0].instance.id,
}),
projectId: project.id,
conflictResolution,
});
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 const reparentInstanceMutable = (
data: Omit<WebstudioData, "pages">,
sourceInstanceSelector: InstanceSelector,
dropTarget: DroppableTarget
) => {
const project = $project.get();
if (project === undefined) {
return;
}
const [rootInstanceId] = sourceInstanceSelector;
// delect is target is one of own descendants
// prevent reparenting to avoid infinite loop
const instanceDescendants = findTreeInstanceIds(
data.instances,
rootInstanceId
);
for (const instanceId of instanceDescendants) {
if (dropTarget.parentSelector.includes(instanceId)) {
return;
}
}
// try to use slot fragment as target instead of slot itself
const parentInstance = data.instances.get(dropTarget.parentSelector[0]);
if (
parentInstance?.component === portalComponent &&
parentInstance.children.length > 0 &&
parentInstance.children[0].type === "id"
) {
const fragmentId = parentInstance.children[0].value;
dropTarget = {
parentSelector: [fragmentId, ...dropTarget.parentSelector],
position: dropTarget.position,
};
}
// move within same parent
if (sourceInstanceSelector[1] === dropTarget.parentSelector[0]) {
const [parentId] = dropTarget.parentSelector;
const parent = data.instances.get(parentId);
if (parent === undefined) {
return;
}
const prevPosition = parent.children.findIndex(
(child) => child.type === "id" && child.value === rootInstanceId
);
const child = parent.children[prevPosition];
parent?.children.splice(prevPosition, 1);
if (dropTarget.position === "end") {
parent?.children.push(child);
} else {
// when parent is the same, we need to adjust the position
// to account for the removal of the instance.
let nextPosition = dropTarget.position;
if (prevPosition < nextPosition) {
nextPosition -= 1;
}
parent?.children.splice(nextPosition, 0, child);
}
return sourceInstanceSelector;
}
// move into another parent
const fragment = extractWebstudioFragment(data, rootInstanceId);
deleteInstanceMutable(
data,
getInstancePath(sourceInstanceSelector, data.instances)
);
// prepare drop target after deleting instance to recreate new slot fragment
dropTarget =
getReparentDropTargetMutable(
data.instances,
data.props,
$registeredComponentMetas.get(),
dropTarget
) ?? dropTarget;
const { newInstanceIds } = insertWebstudioFragmentCopy({
data,
fragment,
availableVariables: findAvailableVariables({
...data,
startingInstanceId: dropTarget.parentSelector[0],
}),
projectId: project.id,
});
const [newParentId] = dropTarget.parentSelector;
const newRootInstanceId =
newInstanceIds.get(rootInstanceId) ?? rootInstanceId;
const newParent = data.instances.get(newParentId);
const newChild = { type: "id" as const, value: newRootInstanceId };
if (dropTarget.position === "end") {
newParent?.children.push(newChild);
} else {
newParent?.children.splice(dropTarget.position, 0, newChild);
}
return [newRootInstanceId, ...dropTarget.parentSelector];
};
export const reparentInstance = (
sourceInstanceSelector: InstanceSelector,
dropTarget: DroppableTarget
) => {
updateWebstudioData((data) => {
const newSelector = reparentInstanceMutable(
data,
sourceInstanceSelector,
dropTarget
);
selectInstance(newSelector);
});
};
export const deleteInstanceMutable = (
data: Omit<WebstudioData, "pages">,
instancePath: undefined | InstancePath
) => {
if (instancePath === undefined) {
return false;
}
const {
instances,
props,
styleSourceSelections,
styleSources,
styles,
dataSources,
resources,
} = data;
let targetInstance = instancePath[0].instance;
let parentInstance =
instancePath.length > 1 ? instancePath[1]?.instance : undefined;
const grandparentInstance =
instancePath.length > 2 ? instancePath[2]?.instance : undefined;
// delete parent fragment too if its last child is going to be deleted
// use case for slots: slot became empty and remove display: contents
// to be displayed properly on canvas
if (
parentInstance?.component === "Fragment" &&
parentInstance.children.length === 1 &&
grandparentInstance
) {
targetInstance = parentInstance;
parentInstance = grandparentInstance;
}
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
instances,
targetInstance.id
);
const localStyleSourceIds = findLocalStyleSourcesWithinInstances(
styleSources.values(),
styleSourceSelections.values(),
instanceIds
);
// mutate instances from data instead of instance path
parentInstance = data.instances.get(parentInstance?.id as string);
// may not exist when delete root
if (parentInstance) {
removeByMutable(
parentInstance.children,
(child) => child.type === "id" && child.value === targetInstance.id
);
}
for (const instanceId of instanceIds) {
instances.delete(instanceId);
}
// delete props, data sources and styles of deleted instance and its descendants
for (const prop of props.values()) {
if (instanceIds.has(prop.instanceId)) {
props.delete(prop.id);
if (prop.type === "resource") {
resources.delete(prop.value);
}
}
}
for (const dataSource of dataSources.values()) {
if (instanceIds.has(dataSource.scopeInstanceId ?? "")) {
dataSources.delete(dataSource.id);
if (dataSource.type === "resource") {
resources.delete(dataSource.resourceId);
}
}
}
for (const instanceId of instanceIds) {
styleSourceSelections.delete(instanceId);
}
deleteLocalStyleSourcesMutable({
localStyleSourceIds,
styleSources,
styles,
});
return true;
};
export const unwrapInstanceMutable = ({
instances,
props,
metas,
selectedItem,
parentItem,
}: {
instances: Map<string, Instance>;
props: Props;
metas: Map<string, WsComponentMeta>;
selectedItem: {
instanceSelector: InstanceSelector;
instance: { id: string };
};
parentItem: { instanceSelector: InstanceSelector; instance: { id: string } };
}): { success: boolean; error?: string } => {
// Check if the selected instance is rich text content (like Bold, Italic in Paragraph)
if (
isRichTextContent({
instanceSelector: selectedItem.instanceSelector,
instances,
props,
metas,
})
) {
return { success: false, error: "Cannot unwrap textual instance" };
}
const parentInstance = instances.get(parentItem.instance.id);
const selectedInstance = instances.get(selectedItem.instance.id);
if (!parentInstance || !selectedInstance) {
return { success: false, error: "Instance not found" };
}
// Get grandparent to replace parent with selected
const grandparentId = parentItem.instanceSelector[1];
if (!grandparentId) {
return { success: false, error: "Cannot unwrap instance at root level" };
}
const grandparentInstance = instances.get(grandparentId);
if (!grandparentInstance) {
return { success: false, error: "Grandparent instance not found" };
}
// Remove selected instance from parent's children
const selectedIndexInParent = parentInstance.children.findIndex(
(child) => child.type === "id" && child.value === selectedItem.instance.id
);
if (selectedIndexInParent !== -1) {
parentInstance.children.splice(selectedIndexInParent, 1);
}
// If parent has no more children, delete it
if (parentInstance.children.length === 0) {
instances.delete(parentItem.instance.id);
}
// Add selected instance to grandparent at parent's position
const parentIndex = grandparentInstance.children.findIndex(
(child) => child.type === "id" && child.value === parentItem.instance.id
);
if (parentIndex !== -1) {
if (parentInstance.children.length === 0) {
// Replace parent with selected if parent is now empty
grandparentInstance.children[parentIndex] = {
type: "id",
value: selectedItem.instance.id,
};
} else {
// Insert selected after parent if parent still has children
grandparentInstance.children.splice(parentIndex + 1, 0, {
type: "id",
value: selectedItem.instance.id,
});
}
}
const matches = isTreeSatisfyingContentModel({
instances,
props,
metas,
instanceSelector: [
selectedItem.instance.id,
...parentItem.instanceSelector.slice(1),
],
});
if (matches === false) {
return { success: false, error: "Cannot unwrap instance" };
}
return { success: true };
};
export const canUnwrapInstance = (instancePath: InstancePath) => {
// Need at least 3 levels: selected, parent, and grandparent
// Can't unwrap if there's no grandparent to move the selected instance to
if (instancePath.length < 3) {
return false;
}
const [selectedItem, parentItem] = instancePath;
// Prevent unwrapping if parent is the root instance (e.g., Body)
const rootInstanceId = $selectedPage.get()?.rootInstanceId;
if (
rootInstanceId !== undefined &&
parentItem.instance.id === rootInstanceId
) {
return false;
}
// Check if the selected instance is rich text content (like Bold, Italic in Paragraph)
const instances = $instances.get();
const props = $props.get();
const metas = $registeredComponentMetas.get();
if (
isRichTextContent({
instanceSelector: selectedItem.instanceSelector,
instances,
props,
metas,
})
) {
return false;
}
return true;
};
export const toggleInstanceShow = (instanceId: Instance["id"]) => {
serverSyncStore.createTransaction([$props], (props) => {
const allProps = Array.from(props.values());
const instanceProps = allProps.filter(
(prop) => prop.instanceId === instanceId
);
let showProp = instanceProps.find((prop) => prop.name === showAttribute);
// Toggle the show value
const newValue = showProp?.type === "boolean" ? !showProp.value : false;
if (showProp === undefined) {
showProp = {
id: nanoid(),
instanceId,
name: showAttribute,
type: "boolean",
value: newValue,
};
}
if (showProp.type === "boolean") {
props.set(showProp.id, { ...showProp, value: newValue });
}
});
};
export const wrapInstance = (component: string, tag?: string) => {
const instancePath = $selectedInstancePath.get();
// global root or body are selected
if (instancePath === undefined || instancePath.length === 1) {
return;
}
const [selectedItem, parentItem] = instancePath;
const selectedInstance = selectedItem.instance;
const newInstanceId = nanoid();
const newInstanceSelector = [newInstanceId, ...parentItem.instanceSelector];
const metas = $registeredComponentMetas.get();
try {
updateWebstudioData((data) => {
const isContent = isRichTextContent({
instanceSelector: selectedItem.instanceSelector,
instances: data.instances,
props: data.props,
metas,
});
if (isContent) {
toast.error(`Cannot wrap textual content`);
throw Error("Abort transaction");
}
const newInstance: Instance = {
type: "instance",
id: newInstanceId,
component,
children: [{ type: "id", value: selectedInstance.id }],
};
if (tag || component === elementComponent) {
newInstance.tag = tag ?? "div";
}
const parentInstance = data.instances.get(parentItem.instance.id);
data.instances.set(newInstanceId, newInstance);
if (parentInstance) {
for (const child of parentInstance.children) {
if (child.type === "id" && child.value === selectedInstance.id) {
child.value = newInstanceId;
}
}
}
const isSatisfying = isTreeSatisfyingContentModel({
instances: data.instances,
props: data.props,
metas,
instanceSelector: newInstanceSelector,
});
if (isSatisfying === false) {
const label = getInstanceLabel({ component, tag });
toast.error(`Cannot wrap in ${label}`);
throw Error("Abort transaction");
}
});
selectInstance(newInstanceSelector);
} catch {
// do nothing
}
};
// Check if an instance can be converted to a specific component or tag
export const canConvertInstance = (
selectedInstanceId: string,
selectedInstanceSelector: string[],
component: string,
tag: string | undefined,
instances: Instances,
props: Props,
metas: Map<Instance["component"], WsComponentMeta>
): boolean => {
const selectedInstance = instances.get(selectedInstanceId);
if (!selectedInstance) {
return false;
}
// Create a test instance with the new component/tag
const testInstance: Instance = {
...selectedInstance,
component,
};
if (tag || component === elementComponent) {
testInstance.tag = tag ?? "div";
} else {
// For components with presetStyle (like Heading, Box), infer default tag
const meta = metas.get(component);
const defaultTag = Object.keys(
(meta as { presetStyle?: Record<string, unknown> })?.presetStyle ?? {}
).at(0);
if (defaultTag) {
testInstance.tag = defaultTag;
}
}
const newInstances = new Map(instances);
newInstances.set(testInstance.id, testInstance);
// Validate the converted instance satisfies content model
return isTreeSatisfyingContentModel({
instances: newInstances,
props,
metas,
instanceSelector: selectedInstanceSelector,
});
};
export const convertInstance = (component: string, tag?: string) => {
const instancePath = $selectedInstancePath.get();
// global root or body are selected
if (instancePath === undefined || instancePath.length === 1) {
return;
}
const [selectedItem] = instancePath;
const selectedInstance = selectedItem.instance;
const selectedInstanceSelector = selectedItem.instanceSelector;
const metas = $registeredComponentMetas.get();
const instanceTags = $instanceTags.get();
try {
updateWebstudioData((data) => {
const instance = data.instances.get(selectedInstance.id);
if (instance === undefined) {
return;
}
instance.component = component;
// convert to specified tag or with currently used
if (tag || component === elementComponent) {
instance.tag = tag ?? instanceTags.get(selectedInstance.id) ?? "div";
// delete legacy tag prop if specified
for (const prop of data.props.values()) {
if (prop.instanceId !== selectedInstance.id) {
continue;
}
if (prop.name === "tag") {
data.props.delete(prop.id);
continue;
}
const newName = reactPropsToStandardAttributes[prop.name];
if (newName) {
const newId = `${prop.instanceId}:${newName}`;
data.props.delete(prop.id);
data.props.set(newId, { ...prop, id: newId, name: newName });
}
}
}
const isSatisfying = isTreeSatisfyingContentModel({
instances: data.instances,
props: data.props,
metas,
instanceSelector: selectedInstanceSelector,
});
if (isSatisfying === false) {
const label = getInstanceLabel({ component, tag });
toast.error(`Cannot convert to ${label}`);
throw Error("Abort transaction");
}
});
} catch {
// do nothing
}
};
export const unwrapInstance = () => {
const instancePath = $selectedInstancePath.get();
if (instancePath === undefined || !canUnwrapInstance(instancePath)) {
return;
}
const [selectedItem, parentItem] = instancePath;
try {
updateWebstudioData((data) => {
const result = unwrapInstanceMutable({
instances: data.instances,
props: data.props,
metas: $registeredComponentMetas.get(),
selectedItem,
parentItem,
});
if (!result.success) {
toast.error(result.error ?? "Cannot unwrap instance");
throw Error("Abort transaction");
}
});
// After unwrap, select the child that replaced the parent
selectInstance([
selectedItem.instance.id,
...parentItem.instanceSelector.slice(1),
]);
} catch {
// do nothing
}
};
export const deleteSelectedInstance = () => {
if ($isPreviewMode.get()) {
return;
}
const textEditingInstanceSelector = $textEditingInstanceSelector.get();
const instancePath = $selectedInstancePath.get();
// cannot delete instance while editing
if (textEditingInstanceSelector) {
return;
}
if (instancePath === undefined || instancePath.length === 1) {
return;
}
const [selectedItem, parentItem] = instancePath;
const selectedInstanceSelector = selectedItem.instanceSelector;
const instances = $instances.get();
if (!isComponentDetachable(selectedItem.instance.component)) {
toast.error(
"This instance can not be moved outside of its parent component."
);
return false;
}
if ($isContentMode.get()) {
// In content mode we are allowing to delete childen of the editable block
const editableInstanceSelector = findBlockSelector(
selectedInstanceSelector,
instances
);
if (editableInstanceSelector === undefined) {
builderApi.toast.info("You can't delete this instance in conent mode.");
return;
}
const isChildOfBlock =
selectedInstanceSelector.length - editableInstanceSelector.length === 1;
const isTemplateInstance =
instances.get(selectedInstanceSelector[0])?.component ===
blockTemplateComponent;
if (isTemplateInstance) {
builderApi.toast.info("You can't delete this instance in content mode.");
return;
}
if (!isChildOfBlock) {
builderApi.toast.info("You can't delete this instance in content mode.");
return;
}
}
// find next selected instance
let newSelectedInstanceSelector: undefined | InstanceSelector;
const parentInstanceSelector = parentItem.instanceSelector;
const siblingIds = parentItem.instance.children
.filter((child) => child.type === "id")
.map((child) => child.value);
const position = siblingIds.indexOf(selectedItem.instance.id);
const siblingId = siblingIds[position + 1] ?? siblingIds[position - 1];
if (siblingId) {
// select next or previous sibling if possible
newSelectedInstanceSelector = [siblingId, ...parentInstanceSelector];
} else {
// fallback to parent
newSelectedInstanceSelector = parentInstanceSelector;
}
updateWebstudioData((data) => {
if (deleteInstanceMutable(data, instancePath)) {
selectInstance(newSelectedInstanceSelector);
}
});
};
const traverseStyleValue = (
value: StyleValue,
callback: (value: StyleValue) => void
) => {
if (
value.type === "fontFamily" ||
value.type === "image" ||
value.type === "unit" ||
value.type === "keyword" ||
value.type === "unparsed" ||
value.type === "invalid" ||
value.type === "unset" ||
value.type === "color" ||
value.type === "rgb" ||
value.type === "function" ||
value.type === "guaranteedInvalid"
) {
callback(value);
return;
}
if (value.type === "var") {
if (value.fallback) {
traverseStyleValue(value.fallback, callback);
}
return;
}
if (value.type === "tuple" || value.type === "layers") {
for (const item of value.value) {
traverseStyleValue(item, callback);
}
return;
}
if (value.type === "shadow") {
traverseStyleValue(value.offsetX, callback);
traverseStyleValue(value.offsetY, callback);
if (value.blur) {
traverseStyleValue(value.blur, callback);
}
if (value.spread) {
traverseStyleValue(value.spread, callback);
}
if (value.color) {
traverseStyleValue(value.color, callback);
}
return;
}
value satisfies never;
};
export const extractWebstudioFragment = (
data: Omit<WebstudioData, "pages">,
rootInstanceId: string,
options: { unsetVariables?: Set<DataSource["id"]> } = {}
): WebstudioFragment => {
const {
assets,
instances,
dataSources,
resources,
props,
styleSourceSelections,
styleSources,
breakpoints,
styles,
} = data;
// collect the instance by id and all its descendants including portal instances
const fragmentInstanceIds = findTreeInstanceIds(instances, rootInstanceId);
let fragmentInstances: Instance[] = [];
// Collect style sources and selections from instances
const {
styleSourceSelectionsArray: fragmentStyleSourceSelections,
styleSourcesMap: fragmentStyleSources,
stylesArray: fragmentStyles,
} = collectStyleSourcesFromInstances({
instanceIds: fragmentInstanceIds,
styleSourceSelections,
styleSources,
styles,
});
for (const instanceId of fragmentInstanceIds) {
const instance = instances.get(instanceId);
if (instance) {
fragmentInstances.push(instance);
}
}
const fragmentAssetIds = new Set<Asset["id"]>();
const fragmentFontFamilies = new Set<string>();
// collect breakpoints and assets from styles
const fragmentBreapoints: Breakpoints = new Map();
for (const styleDecl of fragmentStyles) {
// collect breakpoints
if (fragmentBreapoints.has(styleDecl.breakpointId) === false) {
const breakpoint = breakpoints.get(styleDecl.breakpointId);
if (breakpoint) {
fragmentBreapoints.set(styleDecl.breakpointId, breakpoint);
}
}
// collect assets including fonts
traverseStyleValue(styleDecl.value, (value) => {
if (value.type === "fontFamily") {
for (const fontFamily of value.value) {
fragmentFontFamilies.add(fontFamily);
}
}
if (value.type === "image") {
if (value.value.type === "asset") {
fragmentAssetIds.add(value.value.value);
}
}
});
}
// collect variables scoped to fragment instances
// and variables outside of scope to unset
const fragmentDataSources: DataSources = new Map();
const fragmentResourceIds = new Set<Resource["id"]>();
const unsetNameById = new Map<DataSource["id"], DataSource["name"]>();
const unsetVariables = options.unsetVariables ?? new Set();
for (const dataSource of dataSources.values()) {
if (
fragmentInstanceIds.has(dataSource.scopeInstanceId ?? "") &&
unsetVariables.has(dataSource.id) === false
) {
fragmentDataSources.set(dataSource.id, dataSource);
if (dataSource.type === "resource") {
fragmentResourceIds.add(dataSource.resourceId);
}
} else {
unsetNameById.set(dataSource.id, dataSource.name);
}
}
// unset variables outside of scope
fragmentInstances = fragmentInstances.map((instance) => {
instance = structuredClone(unwrap(instance));
for (const child of instance.children) {
if (child.type === "expression") {
const expression = child.value;
child.value = unsetExpressionVariables({ expression, unsetNameById });
}
}
return instance;
});
// collect props bound to these instances
// and unset variables outside of scope
const fragmentProps: Prop[] = [];
for (const prop of props.values()) {
if (fragmentInstanceIds.has(prop.instanceId) === false) {
continue;
}
if (prop.type === "expression") {
const newProp = structuredClone(unwrap(prop));
const expression = prop.value;
newProp.value = unsetExpressionVariables({ expression, unsetNameById });
fragmentProps.push(newProp);
continue;
}
if (prop.type === "action") {
const newProp = structuredClone(unwrap(prop));
for (const value of newProp.value) {
const expression = value.code;
value.code = unsetExpressionVariables({ expression, unsetNameById });
}
fragmentProps.push(newProp);
continue;
}
fragmentProps.push(prop);
// collect assets
if (prop.type === "asset") {
fragmentAssetIds.add(prop.value);
}
// collect resources from props
if (prop.type === "resource") {
fragmentResourceIds.add(prop.value);
}
}
// collect resources bound to all fragment data sources
// and unset variables which are defined outside of scope
// and used in resource
const fragmentResources: Resource[] = [];
for (const resourceId of fragmentResourceIds) {
const resource = resources.get(resourceId);
if (resource === undefined) {
continue;
}
const newResource = structuredClone(unwrap(resource));
newResource.url = unsetExpressionVariables({
expression: newResource.url,
unsetNameById,
});
for (const header of newResource.headers) {
header.value = unsetExpressionVariables({
expression: header.value,
unsetNameById,
});
}
if (newResource.searchParams) {
for (const searchParam of newResource.searchParams) {
searchParam.value = unsetExpressionVariables({
expression: searchParam.value,
unsetNameById,
});
}
}
if (newResource.body) {
newResource.body = unsetExpressionVariables({
expression: newResource.body,
unsetNameById,
});
}
fragmentResources.push(newResource);
}
const fragmentAssets: Asset[] = [];
for (const asset of assets.values()) {
if (
fragmentAssetIds.has(asset.id) ||
(asset.type === "font" && fragmentFontFamilies.has(asset.meta.family))
) {
fragmentAssets.push(asset);
}
}
return {
children: [{ type: "id", value: rootInstanceId }],
instances: fragmentInstances,
styleSourceSelections: fragmentStyleSourceSelections,
styleSources: Array.from(fragmentStyleSources.values()),
breakpoints: Array.from(fragmentBreapoints.values()),
styles: fragmentStyles,
dataSources: Array.from(fragmentDataSources.values()),
resources: fragmentResources,
props: fragmentProps,
assets: fragmentAssets,
};
};
const replaceDataSources = (
code: string,
replacements: Map<DataSource["id"], DataSource["id"]>
) => {
return transpileExpression({
expression: code,
replaceVariable: (identifier) => {
const dataSourceId = decodeDataSourceVariable(identifier);
if (dataSourceId === undefined) {
return;
}
return encodeDataSourceVariable(
replacements.get(dataSourceId) ?? dataSourceId
);
},
});
};
export const insertWebstudioFragmentCopy = ({
data,
fragment,
availableVariables,
projectId,
conflictResolution = "theirs",
}: {
data: Omit<WebstudioData, "pages">;
fragment: WebstudioFragment;
availableVariables: DataSource[];
projectId: Project["id"];
conflictResolution?: ConflictResolution;
}) => {
const newInstanceIds = new Map<Instance["id"], Instance["id"]>();
const newDataSourceIds = new Map<DataSource["id"], DataSource["id"]>();
const newDataIds = {
newInstanceIds,
newDataSourceIds,
};
const fragmentInstances: Instances = new Map();
const portalContentRootIds = new Set<Instance["id"]>();
for (const instance of fragment.instances) {
fragmentInstances.set(instance.id, instance);
if (instance.component === portalComponent) {
for (const child of instance.children) {
if (child.type === "id") {
portalContentRootIds.add(child.value);
}
}
}
}
const {
assets,
instances,
resources,
dataSources,
props,
breakpoints,
styleSources,
styles,
styleSourceSelections,
} = data;
/**
* insert reusables without changing their ids to not bloat data
* and catch up with user changes
* - assets
* - breakpoints
* - token styles
* - portals
*
* breakpoints behave slightly differently and merged with existing ones
* and those ids are used instead
*/
// insert assets
for (const asset of fragment.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 });
}
}
// merge breakpoints
const mergedBreakpointIds = buildMergedBreakpointIds(
fragment.breakpoints,
breakpoints
);
for (const newBreakpoint of fragment.breakpoints) {
if (mergedBreakpointIds.has(newBreakpoint.id) === false) {
breakpoints.set(newBreakpoint.id, newBreakpoint);
}
}
// insert tokens with their styles
const { styleSourceIds, styleSourceIdMap, updatedStyleSources } =
insertStyleSources({
fragmentStyleSources: fragment.styleSources,
fragmentStyles: fragment.styles,
existingStyleSources: styleSources,
existingStyles: styles,
breakpoints,
mergedBreakpointIds,
conflictResolution,
});
// Update styleSources map with the new tokens
for (const [id, styleSource] of updatedStyleSources) {
styleSources.set(id, styleSource);
}
for (const styleDecl of fragment.styles) {
if (styleSourceIds.has(styleDecl.styleSourceId)) {
const { breakpointId } = styleDecl;
const newStyleDecl: StyleDecl = {
...styleDecl,
breakpointId: mergedBreakpointIds.get(breakpointId) ?? breakpointId,
// Remap the styleSourceId to the new token ID
styleSourceId:
styleSourceIdMap.get(styleDecl.styleSourceId) ??
styleDecl.styleSourceId,
};
styles.set(getStyleDeclKey(newStyleDecl), newStyleDecl);
}
}
let portalContentIds = new Set<Instance["id"]>();
// insert portal contents
// - instances
// - data sources
// - props
// - local styles
for (const rootInstanceId of portalContentRootIds) {
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
fragmentInstances,
rootInstanceId
);
portalContentIds = setUnion(portalContentIds, instanceIds);
// prevent reinserting portals which could be already changed by user
if (instances.has(rootInstanceId)) {
continue;
}
const usedResourceIds = new Set<Resource["id"]>();
for (const dataSource of fragment.dataSources) {
// insert only data sources within portal content
if (instanceIds.has(dataSource.scopeInstanceId ?? "")) {
dataSources.set(dataSource.id, dataSource);
if (dataSource.type === "resource") {
usedResourceIds.add(dataSource.resourceId);
}
}
}
for (const prop of fragment.props) {
if (instanceIds.has(prop.instanceId)) {
props.set(prop.id, prop);
if (prop.type === "resource") {
usedResourceIds.add(prop.value);
}
}
}
for (const resource of fragment.resources) {
if (usedResourceIds.has(resource.id)) {
resources.set(resource.id, resource);
}
}
for (const instance of fragment.instances) {
if (instanceIds.has(instance.id)) {
instances.set(instance.id, instance);
}
}
// insert local style sources with their styles
insertPortalLocalStyleSources({
fragmentStyleSources: fragment.styleSources,
fragmentStyleSourceSelections: fragment.styleSourceSelections,
fragmentStyles: fragment.styles,
instanceIds,
styleSources,
styleSourceSelections,
styles,
mergedBreakpointIds,
});
}
/**
* inserting unique content is structurally similar to inserting portal content
* but all ids are regenerated to support duplicating existing content
* - instances
* - data sources
* - props
* - local styles
*/
// generate new ids only instances outside of portals
const fragmentInstanceIds = setDifference(
new Set(fragmentInstances.keys()),
portalContentIds
);
for (const instanceId of fragmentInstanceIds) {
newInstanceIds.set(instanceId, nanoid());
}
fragmentInstanceIds.add(ROOT_INSTANCE_ID);
newInstanceIds.set(ROOT_INSTANCE_ID, ROOT_INSTANCE_ID);
const maskedIdByName = new Map<DataSource["name"], DataSource["id"]>();
for (const dataSource of availableVariables) {
maskedIdByName.set(dataSource.name, dataSource.id);
}
const newResourceIds = new Map<Resource["id"], Resource["id"]>();
for (let dataSource of fragment.dataSources) {
const scopeInstanceId = dataSource.scopeInstanceId ?? "";
if (scopeInstanceId === ROOT_INSTANCE_ID) {
// add global variable only if not exist already
if (
dataSources.has(dataSource.id) === false &&
maskedIdByName.has(dataSource.name) === false
) {
dataSources.set(dataSource.id, dataSource);
}
continue;
}
// insert only data sources within portal content
if (fragmentInstanceIds.has(scopeInstanceId)) {
const newDataSourceId = nanoid();
newDataSourceIds.set(dataSource.id, newDataSourceId);
dataSource = structuredClone(unwrap(dataSource));
dataSource.id = newDataSourceId;
dataSource.scopeInstanceId =
newInstanceIds.get(scopeInstanceId) ?? scopeInstanceId;
if (dataSource.type === "resource") {
const newResourceId = nanoid();
newResourceIds.set(dataSource.resourceId, newResourceId);
dataSource.resourceId = newResourceId;
}
dataSources.set(dataSource.id, dataSource);
}
}
for (let prop of fragment.props) {
if (fragmentInstanceIds.has(prop.instanceId) === false) {
continue;
}
prop = structuredClone(unwrap(prop));
prop.id = nanoid();
prop.instanceId = newInstanceIds.get(prop.instanceId) ?? prop.instanceId;
if (prop.type === "expression") {
prop.value = restoreExpressionVariables({
expression: prop.value,
maskedIdByName,
});
prop.value = replaceDataSources(prop.value, newDataSourceIds);
}
if (prop.type === "action") {
for (const value of prop.value) {
value.code = restoreExpressionVariables({
expression: value.code,
maskedIdByName,
});
value.code = replaceDataSources(value.code, newDataSourceIds);
}
}
if (prop.type === "parameter") {
prop.value = newDataSourceIds.get(prop.value) ?? prop.value;
}
if (prop.type === "resource") {
const newResourceId = nanoid();
newResourceIds.set(prop.value, newResourceId);
prop.value = newResourceId;
}
props.set(prop.id, prop);
}
for (let resource of fragment.resources) {
if (newResourceIds.has(resource.id) === false) {
continue;
}
resource = structuredClone(unwrap(resource));
resource.id = newResourceIds.get(resource.id) ?? resource.id;
resource.url = restoreExpressionVariables({
expression: resource.url,
maskedIdByName,
});
resource.url = replaceDataSources(resource.url, newDataSourceIds);
for (const header of resource.headers) {
header.value = restoreExpressionVariables({
expression: header.value,
maskedIdByName,
});
header.value = replaceDataSources(header.value, newDataSourceIds);
}
if (resource.searchParams) {
for (const searchParam of resource.searchParams) {
searchParam.value = restoreExpressionVariables({
expression: searchParam.value,
maskedIdByName,
});
searchParam.value = replaceDataSources(
searchParam.value,
newDataSourceIds
);
}
}
if (resource.body) {
resource.body = restoreExpressionVariables({
expression: resource.body,
maskedIdByName,
});
resource.body = replaceDataSources(resource.body, newDataSourceIds);
}
resources.set(resource.id, resource);
}
for (let instance of fragment.instances) {
if (fragmentInstanceIds.has(instance.id)) {
instance = structuredClone(unwrap(instance));
instance.id = newInstanceIds.get(instance.id) ?? instance.id;
for (const child of instance.children) {
if (child.type === "id") {
child.value = newInstanceIds.get(child.value) ?? child.value;
}
if (child.type === "expression") {
child.value = restoreExpressionVariables({
expression: child.value,
maskedIdByName,
});
child.value = replaceDataSources(child.value, newDataSourceIds);
}
}
instances.set(instance.id, instance);
}
}
// insert local styles with new ids
insertLocalStyleSourcesWithNewIds({
fragmentStyleSources: fragment.styleSources,
fragmentStyleSourceSelections: fragment.styleSourceSelections,
fragmentStyles: fragment.styles,
fragmentInstanceIds,
newInstanceIds,
styleSourceIdMap,
styleSources,
styleSourceSelections,
styles,
mergedBreakpointIds,
});
return newDataIds;
};
export const findClosestSlot = (
instances: Instances,
instanceSelector: InstanceSelector
) => {
for (const instanceId of instanceSelector) {
const instance = instances.get(instanceId);
if (instance?.component === "Slot") {
return instance;
}
}
};
export type Insertable = {
parentSelector: InstanceSelector;
position: number | "end" | "after";
};
export const findClosestInsertable = (
fragment: WebstudioFragment,
from?: Insertable
): undefined | Insertable => {
const selectedPage = $selectedPage.get();
const awareness = $awareness.get();
if (selectedPage === undefined) {
return;
}
// paste to the page root if nothing is selected
const instanceSelector = from?.parentSelector ??
awareness?.instanceSelector ?? [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) {
// fallback to closest container to always insert something
// even when validation fails
insertableIndex = 0;
}
// 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,
};
};
/**
* Build the ancestor path array for an instance.
* Returns an array of labels for all ancestors from root to parent.
*/
export const buildInstancePath = (
instanceId: Instance["id"],
pages: Pages,
instances: Instances
): string[] => {
const awareness = findAwarenessByInstanceId(pages, instances, instanceId);
if (!awareness.instanceSelector) {
return [];
}
const instancePath = getInstancePath(
awareness.instanceSelector,
instances,
undefined,
undefined
);
if (!instancePath) {
return [];
}
return instancePath
.slice()
.reverse()
.slice(0, -1) // Remove the instance itself (last element after reverse), keep only ancestors
.map(({ instance }) => getInstanceLabel(instance));
};
/**
* Detects token conflicts for a fragment insertion.
*
* @param fragment - The fragment to check for conflicts
* @returns Array of token conflicts (empty if no conflicts)
*/
export const detectFragmentTokenConflicts = ({
fragment,
}: {
fragment: WebstudioFragment;
}) => {
const data = getWebstudioData();
const mergedBreakpointIds = buildMergedBreakpointIds(
fragment.breakpoints,
data.breakpoints
);
return detectTokenConflicts({
fragmentStyleSources: fragment.styleSources,
fragmentStyles: fragment.styles,
existingStyleSources: data.styleSources,
existingStyles: data.styles,
breakpoints: data.breakpoints,
mergedBreakpointIds,
});
};
/**
* Detects token conflicts for a page insertion.
* Combines fragments from ROOT_INSTANCE and page body for conflict detection.
*
* @param sourceData - The source webstudio data containing the page
* @param pageId - The page ID to check for conflicts
* @returns Array of token conflicts (empty if no conflicts)
*/
export const detectPageTokenConflicts = ({
sourceData,
pageId,
}: {
sourceData: WebstudioData;
pageId: string;
}) => {
const data = getWebstudioData();
const page = sourceData.pages.pages.find((p) => p.id === pageId);
if (page === undefined && sourceData.pages.homePage.id !== pageId) {
throw new Error("Page not found");
}
const targetPage = page ?? sourceData.pages.homePage;
// Extract fragments for both ROOT_INSTANCE and page body
const rootFragment = extractWebstudioFragment(sourceData, ROOT_INSTANCE_ID);
const pageFragment = extractWebstudioFragment(
sourceData,
targetPage.rootInstanceId
);
// Combine style sources and styles from both fragments
const combinedStyleSources = [
...rootFragment.styleSources,
...pageFragment.styleSources,
];
const combinedStyles = [...rootFragment.styles, ...pageFragment.styles];
const combinedBreakpoints = [
...rootFragment.breakpoints,
...pageFragment.breakpoints,
];
const mergedBreakpointIds = buildMergedBreakpointIds(
combinedBreakpoints,
data.breakpoints
);
return detectTokenConflicts({
fragmentStyleSources: combinedStyleSources,
fragmentStyles: combinedStyles,
existingStyleSources: data.styleSources,
existingStyles: data.styles,
breakpoints: data.breakpoints,
mergedBreakpointIds,
});
};