Files
webstudio/apps/builder/app/shared/instance-utils.ts
T
Oleg Isonen 700c9b95ab feat: Improve autoplacement when inserting component via 1-click in components panel or via paste (#3422)
Closes https://github.com/webstudio-is/webstudio/issues/3149

## Description

As a user when I have Heading, Text or similar instances selected which
have a single text node instance, I expect an added component to insert
AFTER such instances.

## Steps for reproduction

1. add heading
2. select heading instance
3. 1-click box
4. see box was added after the heading
5. repeat the same with paste
6. repeat the same with any instance that has only text inside

## Code Review

- [ ] hi @TrySound  , I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)

- [ ] hi @johnsicili , I need you to do
  - 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:
5de6)
- [ ] 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.example`
and the `builder/env-check.js` if mandatory
2024-05-27 10:18:22 +02:00

1522 lines
44 KiB
TypeScript

import { nanoid } from "nanoid";
import { toast } from "@webstudio-is/design-system";
import { equalMedia, type StyleValue } from "@webstudio-is/css-engine";
import {
type Instance,
type Instances,
type StyleSource,
getStyleDeclKey,
findTreeInstanceIds,
StyleSourceSelection,
StyleDecl,
Asset,
StyleSources,
Breakpoints,
DataSources,
Props,
DataSource,
Breakpoint,
type WebstudioFragment,
type WebstudioData,
type Resource,
findTreeInstanceIdsExcludingSlotDescendants,
decodeDataSourceVariable,
encodeDataSourceVariable,
transpileExpression,
getExpressionIdentifiers,
} from "@webstudio-is/sdk";
import {
type WsComponentMeta,
generateDataFromEmbedTemplate,
portalComponent,
collectionComponent,
} from "@webstudio-is/react-sdk";
import {
$props,
$styles,
$selectedInstanceSelector,
$styleSourceSelections,
$styleSources,
$instances,
$registeredComponentMetas,
$dataSources,
$assets,
$project,
$breakpoints,
$pages,
$resources,
$selectedPage,
} from "./nano-states";
import {
type DroppableTarget,
type InstanceSelector,
findLocalStyleSourcesWithinInstances,
getAncestorInstanceSelector,
insertPropsCopyMutable,
getReparentDropTargetMutable,
getInstanceOrCreateFragmentIfNecessary,
wrapEditableChildrenAroundDropTargetMutable,
} from "./tree-utils";
import { removeByMutable } from "./array-utils";
import { isBaseBreakpoint } from "./breakpoints";
import { humanizeString } from "./string-utils";
import { serverSyncStore } from "./sync";
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,
});
}
);
};
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(),
};
};
const getLabelFromComponentName = (component: Instance["component"]) => {
if (component.includes(":")) {
// strip namespace
const [_namespace, baseName] = component.split(":");
component = baseName;
}
return humanizeString(component);
};
export const getInstanceLabel = (
instance: { component: string; label?: string },
meta: WsComponentMeta
) => {
return (
instance.label ||
meta.label ||
getLabelFromComponentName(instance.component)
);
};
export const findClosestEditableInstanceSelector = (
instanceSelector: InstanceSelector,
instances: Instances,
metas: Map<string, WsComponentMeta>
) => {
for (const instanceId of instanceSelector) {
const instance = instances.get(instanceId);
if (instance === undefined) {
return;
}
// when start editing empty body all text content
// including style and scripts appear in editor
// assume body is root and stop checking further
if (instance.component === "Body") {
return;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return;
}
if (meta.type !== "container") {
continue;
}
// only container with rich-text-child children and text can be edited
for (const child of instance.children) {
if (child.type === "id") {
const childInstance = instances.get(child.value);
if (childInstance === undefined) {
return;
}
const childMeta = metas.get(childInstance.component);
if (childMeta?.type !== "rich-text-child") {
return;
}
}
}
return getAncestorInstanceSelector(instanceSelector, instanceId);
}
};
export const findClosestDetachableInstanceSelector = (
instanceSelector: InstanceSelector,
instances: Instances,
metas: Map<string, WsComponentMeta>
) => {
for (const instanceId of instanceSelector) {
const instance = instances.get(instanceId);
if (instance === undefined) {
return;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return;
}
const detachable = meta.detachable ?? true;
if (meta.type === "rich-text-child" || detachable === false) {
continue;
}
return getAncestorInstanceSelector(instanceSelector, instanceId);
}
};
export const isInstanceDetachable = (
instances: Instances,
instanceSelector: InstanceSelector
) => {
const metas = $registeredComponentMetas.get();
const [instanceId] = instanceSelector;
const instance = instances.get(instanceId);
if (instance === undefined) {
return false;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return false;
}
return meta.detachable ?? true;
};
const traverseInstancesConstraints = (
metas: Map<string, WsComponentMeta>,
instances: Instances,
instanceId: Instance["id"],
requiredAncestors: Set<Instance["component"]>,
invalidAncestors: Set<Instance["component"]>,
componentSelector: string[] = []
) => {
const instance = instances.get(instanceId);
if (instance === undefined) {
return;
}
const meta = metas.get(instance.component);
if (meta === undefined) {
return;
}
if (meta.requiredAncestors) {
for (const requiredAncestor of meta.requiredAncestors) {
if (componentSelector.includes(requiredAncestor) === false) {
requiredAncestors.add(requiredAncestor);
}
}
}
if (meta.invalidAncestors) {
for (const invalidAncestor of meta.invalidAncestors) {
invalidAncestors.add(invalidAncestor);
}
}
for (const child of instance.children) {
if (child.type === "id") {
traverseInstancesConstraints(
metas,
instances,
child.value,
requiredAncestors,
invalidAncestors,
[instance.component, ...componentSelector]
);
}
}
};
export type InsertConstraints = {
requiredAncestors: Set<Instance["component"]>;
invalidAncestors: Set<Instance["component"]>;
};
export const computeInstancesConstraints = (
metas: Map<string, WsComponentMeta>,
instances: Instances,
rootInstanceIds: Instance["id"][]
): InsertConstraints => {
const requiredAncestors = new Set<string>();
const invalidAncestors = new Set<string>();
for (const instanceId of rootInstanceIds) {
traverseInstancesConstraints(
metas,
instances,
instanceId,
requiredAncestors,
invalidAncestors
);
}
return {
requiredAncestors,
invalidAncestors,
};
};
export const findClosestDroppableComponentIndex = ({
metas,
constraints,
instances,
instanceSelector,
allowInsertIntoTextContainer = true,
}: {
metas: Map<string, WsComponentMeta>;
constraints: InsertConstraints;
instances: Instances;
instanceSelector: InstanceSelector;
allowInsertIntoTextContainer?: boolean;
}) => {
const { requiredAncestors, invalidAncestors } = constraints;
const componentSelector: string[] = [];
for (const instanceId of instanceSelector) {
const instance = instances.get(instanceId);
if (instance === undefined) {
componentSelector.push("Fragment");
continue;
}
// Collection produce fake instances and fragment does not have constraints.
componentSelector.push(instance.component);
}
let containerIndex = -1;
let requiredFound = false;
for (let index = 0; index < componentSelector.length; index += 1) {
const ancestorComponent = componentSelector[index];
if (invalidAncestors.has(ancestorComponent) === true) {
containerIndex = -1;
requiredFound = false;
continue;
}
if (allowInsertIntoTextContainer === false) {
const instance = instances.get(instanceSelector[index]);
if (instance !== undefined) {
const hasTextChild = instance.children.some(
(child) => child.type === "text" || child.type === "expression"
);
if (hasTextChild) {
containerIndex = -1;
continue;
}
}
}
if (requiredAncestors.has(ancestorComponent) === true) {
requiredFound = true;
}
const ancestorMeta = metas.get(ancestorComponent);
if (containerIndex === -1 && ancestorMeta?.type === "container") {
containerIndex = index;
}
}
if (requiredFound || requiredAncestors.size === 0) {
return containerIndex;
}
return -1;
};
export const findClosestDroppableTarget = (
metas: Map<string, WsComponentMeta>,
instances: Instances,
instanceSelector: InstanceSelector,
insertConstraints: InsertConstraints
): undefined | DroppableTarget => {
const droppableIndex = findClosestDroppableComponentIndex({
metas,
constraints: insertConstraints,
instances,
instanceSelector,
allowInsertIntoTextContainer: false,
});
if (droppableIndex === -1) {
return;
}
if (droppableIndex === 0) {
return {
parentSelector: instanceSelector,
position: "end",
};
}
const dropTargetSelector = instanceSelector.slice(droppableIndex);
const dropTargetInstanceId = instanceSelector[droppableIndex];
const dropTargetInstance = instances.get(dropTargetInstanceId);
if (dropTargetInstance === undefined) {
return;
}
const lastChildInstanceId = instanceSelector[droppableIndex - 1];
const lastChildPosition = dropTargetInstance.children.findIndex(
(child) => child.type === "id" && child.value === lastChildInstanceId
);
return {
parentSelector: dropTargetSelector,
position: lastChildPosition + 1,
};
};
export const insertInstanceChildrenMutable = (
data: WebstudioData,
children: Instance["children"],
insertTarget: DroppableTarget
) => {
const metas = $registeredComponentMetas.get();
insertTarget =
getInstanceOrCreateFragmentIfNecessary(data.instances, insertTarget) ??
insertTarget;
insertTarget =
wrapEditableChildrenAroundDropTargetMutable(
data.instances,
data.props,
metas,
insertTarget
) ?? insertTarget;
const [parentInstanceId] = insertTarget.parentSelector;
const parentInstance = data.instances.get(parentInstanceId);
if (parentInstance === undefined) {
return;
}
const { position } = insertTarget;
if (position === "end") {
parentInstance.children.push(...children);
} else {
parentInstance.children.splice(position, 0, ...children);
}
};
export const findTargetAndInsertFragment = (fragment: WebstudioFragment) => {
let isSuccess = false;
const selectedPage = $selectedPage.get();
if (selectedPage === undefined) {
return isSuccess;
}
const metas = $registeredComponentMetas.get();
const newInstances = new Map(
fragment.instances.map((instance) => [instance.id, instance])
);
const rootInstanceIds = fragment.children
.filter((child) => child.type === "id")
.map((child) => child.value);
// paste to the root if nothing is selected
const instanceSelector = $selectedInstanceSelector.get() ?? [
selectedPage.rootInstanceId,
];
const target = findClosestDroppableTarget(
metas,
$instances.get(),
instanceSelector,
computeInstancesConstraints(metas, newInstances, rootInstanceIds)
);
if (target === undefined) {
return isSuccess;
}
updateWebstudioData((data) => {
const { newInstanceIds } = insertWebstudioFragmentCopy({
data,
fragment,
availableDataSources: findAvailableDataSources(
data.dataSources,
data.instances,
instanceSelector
),
});
const newRootInstanceId = newInstanceIds.get(fragment.instances[0].id);
if (newRootInstanceId === undefined) {
isSuccess = false;
return;
}
const children: Instance["children"] = [
{ type: "id", value: newRootInstanceId },
];
insertInstanceChildrenMutable(data, children, target);
isSuccess = true;
});
return isSuccess;
};
export const insertTemplateData = (
templateData: WebstudioFragment,
dropTarget: DroppableTarget
) => {
const {
children,
instances: insertedInstances,
props: insertedProps,
dataSources: insertedDataSources,
} = templateData;
const rootInstanceId = insertedInstances[0].id;
updateWebstudioData((data) => {
const {
instances,
dataSources,
props,
styleSourceSelections,
styleSources,
styles,
} = data;
for (const instance of insertedInstances) {
instances.set(instance.id, instance);
}
insertInstanceChildrenMutable(data, children, dropTarget);
insertPropsCopyMutable(props, insertedProps, new Map());
for (const dataSource of insertedDataSources) {
dataSources.set(dataSource.id, dataSource);
}
// insert only new style sources and their styles to support
// embed template tokens which have persistent id
// so when user changes these styles and then again add component with token
// nothing breaks visually
const insertedStyleSources = new Set<StyleSource["id"]>();
for (const styleSource of templateData.styleSources) {
if (styleSources.has(styleSource.id) === false) {
insertedStyleSources.add(styleSource.id);
styleSources.set(styleSource.id, styleSource);
}
}
for (const styleDecl of templateData.styles) {
if (insertedStyleSources.has(styleDecl.styleSourceId)) {
styles.set(getStyleDeclKey(styleDecl), styleDecl);
}
}
for (const styleSourceSelection of templateData.styleSourceSelections) {
styleSourceSelections.set(
styleSourceSelection.instanceId,
styleSourceSelection
);
}
});
$selectedInstanceSelector.set([rootInstanceId, ...dropTarget.parentSelector]);
};
export const getComponentTemplateData = (component: string) => {
const metas = $registeredComponentMetas.get();
const componentMeta = metas.get(component);
// when template not specified fallback to template with the component
const template = componentMeta?.template ?? [
{
type: "instance",
component,
children: [],
},
];
const breakpoints = $breakpoints.get();
const breakpointValues = Array.from(breakpoints.values());
const baseBreakpoint = breakpointValues.find(isBaseBreakpoint);
if (baseBreakpoint === undefined) {
return;
}
return generateDataFromEmbedTemplate(template, metas, baseBreakpoint.id);
};
export const reparentInstance = (
sourceInstanceSelector: InstanceSelector,
dropTarget: DroppableTarget
) => {
const [rootInstanceId] = sourceInstanceSelector;
updateWebstudioData((data) => {
const fragment = extractWebstudioFragment(data, rootInstanceId);
const reparentDropTarget = getReparentDropTargetMutable(
data.instances,
data.props,
$registeredComponentMetas.get(),
sourceInstanceSelector,
dropTarget
);
if (reparentDropTarget === undefined) {
return;
}
deleteInstanceMutable(data, sourceInstanceSelector);
const { newInstanceIds } = insertWebstudioFragmentCopy({
data,
fragment,
availableDataSources: findAvailableDataSources(
data.dataSources,
data.instances,
reparentDropTarget.parentSelector
),
});
const newRootInstanceId = newInstanceIds.get(rootInstanceId);
if (newRootInstanceId === undefined) {
return;
}
const [newParentId] = reparentDropTarget.parentSelector;
const newParent = data.instances.get(newParentId);
if (newParent === undefined) {
return;
}
const newChild = { type: "id" as const, value: newRootInstanceId };
if (reparentDropTarget.position === "end") {
newParent.children.push(newChild);
} else {
newParent.children.splice(reparentDropTarget.position, 0, newChild);
}
$selectedInstanceSelector.set([
newRootInstanceId,
...reparentDropTarget.parentSelector,
]);
});
};
export const deleteInstanceMutable = (
data: WebstudioData,
instanceSelector: InstanceSelector
) => {
// @todo tell user they can't delete root
if (instanceSelector.length === 1) {
return false;
}
if (isInstanceDetachable(data.instances, instanceSelector) === false) {
toast.error(
"This instance can not be moved outside of its parent component."
);
return false;
}
const {
instances,
props,
styleSourceSelections,
styleSources,
styles,
dataSources,
resources,
} = data;
let targetInstanceId = instanceSelector[0];
const parentInstanceId = instanceSelector[1];
let parentInstance =
parentInstanceId === undefined
? undefined
: instances.get(parentInstanceId);
const grandparentInstanceId = instanceSelector[2];
const grandparentInstance = instances.get(grandparentInstanceId);
// 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
) {
targetInstanceId = parentInstance.id;
parentInstance = grandparentInstance;
}
// skip parent fake "item" instance and use grandparent collection as parent
if (grandparentInstance?.component === collectionComponent) {
parentInstance = grandparentInstance;
}
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
instances,
targetInstanceId
);
const localStyleSourceIds = findLocalStyleSourcesWithinInstances(
styleSources.values(),
styleSourceSelections.values(),
instanceIds
);
// may not exist when delete root
if (parentInstance) {
removeByMutable(
parentInstance.children,
(child) => child.type === "id" && child.value === targetInstanceId
);
}
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);
}
}
for (const dataSource of dataSources.values()) {
if (
dataSource.scopeInstanceId !== undefined &&
instanceIds.has(dataSource.scopeInstanceId)
) {
dataSources.delete(dataSource.id);
if (dataSource.type === "resource") {
resources.delete(dataSource.resourceId);
}
}
}
for (const instanceId of instanceIds) {
styleSourceSelections.delete(instanceId);
}
for (const styleSourceId of localStyleSourceIds) {
styleSources.delete(styleSourceId);
}
for (const [styleDeclKey, styleDecl] of styles) {
if (localStyleSourceIds.has(styleDecl.styleSourceId)) {
styles.delete(styleDeclKey);
}
}
return true;
};
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 === "rgb" ||
value.type === "function" ||
value.type === "guaranteedInvalid"
) {
callback(value);
return;
}
if (value.type === "var") {
for (const item of value.fallbacks) {
traverseStyleValue(item, callback);
}
return;
}
if (value.type === "tuple" || value.type === "layers") {
for (const item of value.value) {
traverseStyleValue(item, callback);
}
return;
}
value satisfies never;
};
const collectUsedDataSources = (
expression: string,
usedDataSourceIds: Set<DataSource["id"]>
) => {
const identifiers = getExpressionIdentifiers(expression);
for (const identifier of identifiers) {
const id = decodeDataSourceVariable(identifier);
if (id !== undefined) {
usedDataSourceIds.add(id);
}
}
};
export const extractWebstudioFragment = (
data: WebstudioData,
rootInstanceId: string
): 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);
const fragmentInstances: Instance[] = [];
const fragmentStyleSourceSelections: StyleSourceSelection[] = [];
const fragmentStyleSources: StyleSources = new Map();
const usedDataSourceIds = new Set<DataSource["id"]>();
for (const instanceId of fragmentInstanceIds) {
const instance = instances.get(instanceId);
if (instance) {
fragmentInstances.push(instance);
for (const child of instance.children) {
if (child.type === "expression") {
collectUsedDataSources(child.value, usedDataSourceIds);
}
}
}
// collect all style sources bound to these instances
const styleSourceSelection = styleSourceSelections.get(instanceId);
if (styleSourceSelection) {
fragmentStyleSourceSelections.push(styleSourceSelection);
for (const styleSourceId of styleSourceSelection.values) {
if (fragmentStyleSources.has(styleSourceId)) {
continue;
}
const styleSource = styleSources.get(styleSourceId);
if (styleSource === undefined) {
continue;
}
fragmentStyleSources.set(styleSourceId, styleSource);
}
}
}
const fragmentAssetIds = new Set<Asset["id"]>();
const fragmentFontFamilies = new Set<string>();
// collect styles bound to these style sources
const fragmentStyles: StyleDecl[] = [];
const fragmentBreapoints: Breakpoints = new Map();
for (const styleDecl of styles.values()) {
if (fragmentStyleSources.has(styleDecl.styleSourceId) === false) {
continue;
}
fragmentStyles.push(styleDecl);
// 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 props bound to these instances
const fragmentProps: Props = new Map();
for (const prop of props.values()) {
if (fragmentInstanceIds.has(prop.instanceId) === false) {
continue;
}
fragmentProps.set(prop.id, prop);
if (prop.type === "expression") {
collectUsedDataSources(prop.value, usedDataSourceIds);
}
if (prop.type === "action") {
for (const value of prop.value) {
if (value.type === "execute") {
collectUsedDataSources(value.code, usedDataSourceIds);
}
}
}
// collect assets
if (prop.type === "asset") {
fragmentAssetIds.add(prop.value);
}
}
// collect variables scoped to fragment instances
// or used by expressions or actions even outside of the tree
// such variables can be bound to fragment root on paste
const fragmentDataSources: DataSources = new Map();
const fragmentResourceIds = new Set<Resource["id"]>();
for (const dataSource of dataSources.values()) {
if (
// check if data source itself can be copied
(dataSource.scopeInstanceId !== undefined &&
fragmentInstanceIds.has(dataSource.scopeInstanceId)) ||
usedDataSourceIds.has(dataSource.id)
) {
fragmentDataSources.set(dataSource.id, dataSource);
if (dataSource.type === "resource") {
fragmentResourceIds.add(dataSource.resourceId);
}
}
}
// collect resources bound to all fragment data sources
// and then collect data sources used in these resources
// it creates some recursive behavior but since resources
// cannot depend on other resources all left data sources
// can be collected just once
const fragmentResources: Resource[] = [];
const dataSourceIdsUsedInResources = new Set<DataSource["id"]>();
for (const resourceId of fragmentResourceIds) {
const resource = resources.get(resourceId);
if (resource === undefined) {
continue;
}
fragmentResources.push(resource);
collectUsedDataSources(resource.url, dataSourceIdsUsedInResources);
for (const { value } of resource.headers) {
collectUsedDataSources(value, dataSourceIdsUsedInResources);
}
if (resource.body) {
collectUsedDataSources(resource.body, dataSourceIdsUsedInResources);
}
}
for (const dataSource of dataSources.values()) {
if (dataSourceIdsUsedInResources.has(dataSource.id)) {
fragmentDataSources.set(dataSource.id, dataSource);
}
}
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: Array.from(fragmentProps.values()),
assets: fragmentAssets,
};
};
export const findAvailableDataSources = (
dataSources: DataSources,
instances: Instances,
instanceSelector: InstanceSelector
) => {
// inline data sources not scoped to current portal
const instanceIds = new Set();
for (const instanceId of instanceSelector) {
const instance = instances.get(instanceId);
if (instance?.component === portalComponent) {
break;
}
instanceIds.add(instanceId);
}
const availableDataSources = new Set<DataSource["id"]>();
for (const { id, scopeInstanceId } of dataSources.values()) {
if (scopeInstanceId && instanceIds.has(scopeInstanceId)) {
availableDataSources.add(id);
}
}
return availableDataSources;
};
const inlineUnavailableDataSources = ({
code,
availableDataSources,
dataSources,
}: {
code: string;
availableDataSources: Set<DataSource["id"]>;
dataSources: DataSources;
}) => {
let isDiscarded = false;
const newCode = transpileExpression({
expression: code,
replaceVariable: (identifier, assignee) => {
const dataSourceId = decodeDataSourceVariable(identifier);
if (
dataSourceId === undefined ||
availableDataSources.has(dataSourceId)
) {
return;
}
// left operand of assign operator cannot be inlined
if (assignee) {
isDiscarded = true;
}
const dataSource = dataSources.get(dataSourceId);
// inline variable not scoped to portal content instances
if (dataSource?.type === "variable") {
return JSON.stringify(dataSource.value.value);
}
return "{}";
},
});
return { code: newCode, isDiscarded };
};
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,
availableDataSources,
}: {
data: WebstudioData;
fragment: WebstudioFragment;
availableDataSources: Set<DataSource["id"]>;
}) => {
const newInstanceIds = new Map<Instance["id"], Instance["id"]>();
const newDataSourceIds = new Map<DataSource["id"], DataSource["id"]>();
const newDataIds = {
newInstanceIds,
newDataSourceIds,
};
const projectId = $project.get()?.id;
if (projectId === undefined) {
return newDataIds;
}
const fragmentInstances: Instances = new Map();
const portalContentIds = 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") {
portalContentIds.add(child.value);
}
}
}
}
const fragmentDataSources: DataSources = new Map();
for (const dataSource of fragment.dataSources) {
fragmentDataSources.set(dataSource.id, dataSource);
}
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 = new Map<Breakpoint["id"], Breakpoint["id"]>();
for (const newBreakpoint of fragment.breakpoints) {
let matched = false;
for (const breakpoint of breakpoints.values()) {
if (equalMedia(breakpoint, newBreakpoint)) {
matched = true;
mergedBreakpointIds.set(newBreakpoint.id, breakpoint.id);
break;
}
}
if (matched === false) {
breakpoints.set(newBreakpoint.id, newBreakpoint);
}
}
// insert tokens with their styles
const tokenStyleSourceIds = new Set<StyleSource["id"]>();
for (const styleSource of fragment.styleSources) {
// prevent inserting styles when token is already present
if (styleSource.type === "local" || styleSources.has(styleSource.id)) {
continue;
}
styleSource.type satisfies "token";
tokenStyleSourceIds.add(styleSource.id);
styleSources.set(styleSource.id, styleSource);
}
for (const styleDecl of fragment.styles) {
if (tokenStyleSourceIds.has(styleDecl.styleSourceId)) {
const { breakpointId } = styleDecl;
const newStyleDecl: StyleDecl = {
...styleDecl,
breakpointId: mergedBreakpointIds.get(breakpointId) ?? breakpointId,
};
styles.set(getStyleDeclKey(newStyleDecl), newStyleDecl);
}
}
// insert portal contents
// - instances
// - data sources
// - props
// - local styles
for (const rootInstanceId of portalContentIds) {
// prevent reinserting portals which could be already changed by user
if (instances.has(rootInstanceId)) {
continue;
}
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
fragmentInstances,
rootInstanceId
);
const availablePortalDataSources = new Set(availableDataSources);
const usedResourceIds = new Set<Resource["id"]>();
for (const dataSource of fragment.dataSources) {
// insert only data sources within portal content
if (
dataSource.scopeInstanceId &&
instanceIds.has(dataSource.scopeInstanceId)
) {
availablePortalDataSources.add(dataSource.id);
dataSources.set(dataSource.id, dataSource);
if (dataSource.type === "resource") {
usedResourceIds.add(dataSource.resourceId);
}
}
}
for (const resource of fragment.resources) {
if (usedResourceIds.has(resource.id) === false) {
continue;
}
const newUrl = inlineUnavailableDataSources({
code: resource.url,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
}).code;
const newHeaders = resource.headers.map((header) => ({
name: header.name,
value: inlineUnavailableDataSources({
code: header.value,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
}).code,
}));
const newBody =
resource.body === undefined
? undefined
: inlineUnavailableDataSources({
code: resource.body,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
}).code;
resources.set(resource.id, {
...resource,
url: newUrl,
headers: newHeaders,
body: newBody,
});
}
for (const instance of fragment.instances) {
if (instanceIds.has(instance.id)) {
instances.set(instance.id, {
...instance,
children: instance.children.map((child) => {
if (child.type === "expression") {
const { code } = inlineUnavailableDataSources({
code: child.value,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
});
return {
type: "expression",
value: code,
};
}
return child;
}),
});
}
}
for (let prop of fragment.props) {
if (instanceIds.has(prop.instanceId) === false) {
continue;
}
// inline data sources not available in scope into expressions
if (prop.type === "expression") {
const { code } = inlineUnavailableDataSources({
code: prop.value,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
});
prop = { ...prop, value: code };
}
if (prop.type === "action") {
prop = {
...prop,
value: prop.value.flatMap((value) => {
if (value.type !== "execute") {
return [value];
}
const { code, isDiscarded } = inlineUnavailableDataSources({
code: value.code,
availableDataSources: availablePortalDataSources,
dataSources: fragmentDataSources,
});
if (isDiscarded) {
return [];
}
return [{ ...value, code }];
}),
};
}
props.set(prop.id, prop);
}
// insert local style sources with their styles
const instanceStyleSourceIds = new Set<StyleSource["id"]>();
for (const styleSourceSelection of fragment.styleSourceSelections) {
const { instanceId } = styleSourceSelection;
if (instanceIds.has(instanceId) === false) {
continue;
}
styleSourceSelections.set(instanceId, styleSourceSelection);
for (const styleSourceId of styleSourceSelection.values) {
instanceStyleSourceIds.add(styleSourceId);
}
}
const localStyleSourceIds = new Set<StyleSource["id"]>();
for (const styleSource of fragment.styleSources) {
if (
styleSource.type === "local" &&
instanceStyleSourceIds.has(styleSource.id)
) {
localStyleSourceIds.add(styleSource.id);
styleSources.set(styleSource.id, styleSource);
}
}
for (const styleDecl of fragment.styles) {
if (localStyleSourceIds.has(styleDecl.styleSourceId)) {
const { breakpointId } = styleDecl;
const newStyleDecl: StyleDecl = {
...styleDecl,
breakpointId: mergedBreakpointIds.get(breakpointId) ?? breakpointId,
};
styles.set(getStyleDeclKey(newStyleDecl), newStyleDecl);
}
}
}
/**
* 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 = findTreeInstanceIdsExcludingSlotDescendants(
fragmentInstances,
fragment.instances[0].id
);
for (const instanceId of fragmentInstanceIds) {
newInstanceIds.set(instanceId, nanoid());
}
const availableFragmentDataSources = new Set(availableDataSources);
const newResourceIds = new Map<Resource["id"], Resource["id"]>();
const usedResourceIds = new Set<Resource["id"]>();
for (const dataSource of fragment.dataSources) {
const { scopeInstanceId } = dataSource;
// insert only data sources within portal content
if (scopeInstanceId && fragmentInstanceIds.has(scopeInstanceId)) {
availableFragmentDataSources.add(dataSource.id);
const newDataSourceId = nanoid();
newDataSourceIds.set(dataSource.id, newDataSourceId);
if (dataSource.type === "resource") {
const newResourceId = nanoid();
newResourceIds.set(dataSource.resourceId, newResourceId);
usedResourceIds.add(dataSource.resourceId);
dataSources.set(newDataSourceId, {
...dataSource,
id: newDataSourceId,
scopeInstanceId: newInstanceIds.get(scopeInstanceId),
resourceId: newResourceId,
});
} else {
dataSources.set(newDataSourceId, {
...dataSource,
id: newDataSourceId,
scopeInstanceId: newInstanceIds.get(scopeInstanceId),
});
}
}
}
for (const resource of fragment.resources) {
if (usedResourceIds.has(resource.id) === false) {
continue;
}
const newResourceId = newResourceIds.get(resource.id) ?? resource.id;
const newUrl = replaceDataSources(
inlineUnavailableDataSources({
code: resource.url,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
}).code,
newDataSourceIds
);
const newHeaders = resource.headers.map((header) => ({
name: header.name,
value: replaceDataSources(
inlineUnavailableDataSources({
code: header.value,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
}).code,
newDataSourceIds
),
}));
const newBody =
resource.body === undefined
? undefined
: replaceDataSources(
inlineUnavailableDataSources({
code: resource.body,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
}).code,
newDataSourceIds
);
resources.set(newResourceId, {
...resource,
id: newResourceId,
url: newUrl,
headers: newHeaders,
body: newBody,
});
}
for (const instance of fragment.instances) {
if (fragmentInstanceIds.has(instance.id)) {
const newId = newInstanceIds.get(instance.id) ?? instance.id;
instances.set(newId, {
...instance,
id: newId,
children: instance.children.map((child) => {
if (child.type === "id") {
return {
type: "id",
value: newInstanceIds.get(child.value) ?? child.value,
};
}
if (child.type === "expression") {
const { code } = inlineUnavailableDataSources({
code: child.value,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
});
return {
type: "expression",
value: replaceDataSources(code, newDataSourceIds),
};
}
return child;
}),
});
}
}
for (let prop of fragment.props) {
if (fragmentInstanceIds.has(prop.instanceId) === false) {
continue;
}
// inline data sources not available in scope into expressions
if (prop.type === "expression") {
const { code } = inlineUnavailableDataSources({
code: prop.value,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
});
prop = { ...prop, value: replaceDataSources(code, newDataSourceIds) };
}
if (prop.type === "action") {
prop = {
...prop,
value: prop.value.flatMap((value) => {
if (value.type !== "execute") {
return [value];
}
const { code, isDiscarded } = inlineUnavailableDataSources({
code: value.code,
availableDataSources: availableFragmentDataSources,
dataSources: fragmentDataSources,
});
if (isDiscarded) {
return [];
}
return [
{ ...value, code: replaceDataSources(code, newDataSourceIds) },
];
}),
};
}
if (prop.type === "parameter") {
prop = {
...prop,
value: newDataSourceIds.get(prop.value) ?? prop.value,
};
}
const newId = nanoid();
props.set(newId, {
...prop,
id: newId,
instanceId: newInstanceIds.get(prop.instanceId) ?? prop.instanceId,
});
}
// insert local styles with new ids
const instanceStyleSourceIds = new Set<StyleSource["id"]>();
for (const styleSourceSelection of fragment.styleSourceSelections) {
if (fragmentInstanceIds.has(styleSourceSelection.instanceId) === false) {
continue;
}
for (const styleSourceId of styleSourceSelection.values) {
instanceStyleSourceIds.add(styleSourceId);
}
}
const newLocalStyleSourceIds = new Map<
StyleSource["id"],
StyleSource["id"]
>();
for (const styleSource of fragment.styleSources) {
if (
styleSource.type === "local" &&
instanceStyleSourceIds.has(styleSource.id)
) {
const newId = nanoid();
newLocalStyleSourceIds.set(styleSource.id, newId);
styleSources.set(newId, { ...styleSource, id: newId });
}
}
for (const styleSourceSelection of fragment.styleSourceSelections) {
const { instanceId, values } = styleSourceSelection;
if (fragmentInstanceIds.has(instanceId) === false) {
continue;
}
const newInstanceId = newInstanceIds.get(instanceId) ?? instanceId;
styleSourceSelections.set(newInstanceId, {
instanceId: newInstanceId,
values: values.map(
(styleSourceId) =>
newLocalStyleSourceIds.get(styleSourceId) ?? styleSourceId
),
});
for (const styleSourceId of styleSourceSelection.values) {
instanceStyleSourceIds.add(styleSourceId);
}
}
for (const styleDecl of fragment.styles) {
const { breakpointId, styleSourceId } = styleDecl;
if (newLocalStyleSourceIds.has(styleDecl.styleSourceId)) {
const newStyleDecl: StyleDecl = {
...styleDecl,
styleSourceId:
newLocalStyleSourceIds.get(styleSourceId) ?? styleSourceId,
breakpointId: mergedBreakpointIds.get(breakpointId) ?? breakpointId,
};
styles.set(getStyleDeclKey(newStyleDecl), newStyleDecl);
}
}
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;
}
}
};