Files
webstudio/apps/builder/app/shared/tree-utils.ts
T
Bogdan Chadkin 47d0517223 fix: reparent within another slot instance (#5021)
Ivan found a bug. Reparent slot child into another instance of the same
slot got this child removed.

Now refactored whole thing (almost). The main difference is same parent
move will not do delete/paste but will just update child position. This
fixes bug above and makes other logic slightly simpler.

Rewritten reparentInstance with reparentInstanceMutable to simplify
testing and make it composable (if ever need). Added more tests.
2025-03-20 21:21:09 +07:00

296 lines
8.1 KiB
TypeScript

import { nanoid } from "nanoid";
import { shallowEqual } from "shallow-equal";
import type {
Instance,
Instances,
Prop,
Props,
StyleDecl,
Styles,
StyleSource,
StyleSourceSelection,
WsComponentMeta,
} from "@webstudio-is/sdk";
import { collectionComponent } from "@webstudio-is/sdk";
// slots can have multiple parents so instance should be addressed
// with full rendered path to avoid double selections with slots
// and support deletion of slot child from specific parent
// selector starts with target instance and ends with root
export type InstanceSelector = Instance["id"][];
// provide a selector starting with ancestor id
// useful to select parent instance or one of breadcrumbs instances
export const getAncestorInstanceSelector = (
instanceSelector: InstanceSelector,
ancestorId: Instance["id"]
): undefined | InstanceSelector => {
const ancestorIndex = instanceSelector.indexOf(ancestorId);
if (ancestorIndex === -1) {
return undefined;
}
return instanceSelector.slice(ancestorIndex);
};
export const areInstanceSelectorsEqual = (
left?: InstanceSelector,
right?: InstanceSelector
) => {
if (left === undefined || right === undefined) {
return false;
}
return left.join(",") === right.join(",");
};
export const isDescendantOrSelf = (
descendant: InstanceSelector,
self: InstanceSelector
) => {
if (self.length === 0) {
return true;
}
if (descendant.length < self.length) {
return false;
}
const endSlice = descendant.slice(-self.length);
return shallowEqual(endSlice, self);
};
export type DroppableTarget = {
parentSelector: InstanceSelector;
position: number | "end";
};
const getCollectionDropTarget = (
instances: Instances,
dropTarget: DroppableTarget
) => {
const [parentId, grandparentId] = dropTarget.parentSelector;
const parent = instances.get(parentId);
const grandparent = instances.get(grandparentId);
if (parent === undefined && grandparent?.component === collectionComponent) {
return {
parentSelector: dropTarget.parentSelector.slice(1),
position: dropTarget.position,
};
}
};
export const getInstanceOrCreateFragmentIfNecessary = (
instances: Instances,
dropTarget: DroppableTarget
) => {
const [parentId] = dropTarget.parentSelector;
const instance = instances.get(parentId);
if (instance === undefined) {
return;
}
// slot should accept only single child
// otherwise multiple slots will have to maintain own children
// here all slot children are wrapped with fragment instance
if (instance.component === "Slot") {
if (instance.children.length === 0) {
const id = nanoid();
const fragment: Instance = {
type: "instance",
id,
component: "Fragment",
children: [],
};
instances.set(id, fragment);
instance.children.push({ type: "id", value: id });
return {
parentSelector: [fragment.id, ...dropTarget.parentSelector],
position: dropTarget.position,
};
}
// first slot child is always fragment
if (instance.children[0].type === "id") {
const fragmentId = instance.children[0].value;
return {
parentSelector: [fragmentId, ...dropTarget.parentSelector],
position: dropTarget.position,
};
}
}
return;
};
/**
* Navigator tree and canvas dnd do not have text representation
* and position does not consider it and include only instances.
* This function adjust the position to consider text children
*/
const adjustChildrenPosition = (
children: Instance["children"],
position: number
) => {
let newPosition = 0;
let idPosition = 0;
for (let index = 0; index < children.length; index += 1) {
newPosition = index;
if (idPosition === position) {
return newPosition;
}
const child = children[index];
if (child.type === "id") {
idPosition += 1;
}
}
// the index after last item
return newPosition + 1;
};
/**
* Wrap children before and after drop target with spans
* to preserve lexical specific components while allowing
* to insert into editable components
*/
export const wrapEditableChildrenAroundDropTargetMutable = (
instances: Instances,
props: Props,
metas: Map<string, WsComponentMeta>,
dropTarget: DroppableTarget
) => {
const [parentId] = dropTarget.parentSelector;
const parentInstance = instances.get(parentId);
if (parentInstance === undefined || parentInstance.children.length === 0) {
return;
}
// wrap only containers with text and rich text childre
for (const child of parentInstance.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;
}
}
}
const position =
dropTarget.position === "end"
? parentInstance.children.length
: adjustChildrenPosition(parentInstance.children, dropTarget.position);
const newChildren: Instance["children"] = [];
let newPosition = 0;
// create left span when not at the beginning
if (position !== 0) {
const leftSpan: Instance = {
id: nanoid(),
type: "instance",
component: "Text",
children: parentInstance.children.slice(0, position),
};
newChildren.push({ type: "id", value: leftSpan.id });
instances.set(leftSpan.id, leftSpan);
const tagProp: Prop = {
id: nanoid(),
instanceId: leftSpan.id,
type: "string",
name: "tag",
value: "span",
};
props.set(tagProp.id, tagProp);
newPosition = 1;
}
// create right span when not in the end
if (position < parentInstance.children.length) {
const rightSpan: Instance = {
id: nanoid(),
type: "instance",
component: "Text",
children: parentInstance.children.slice(position),
};
newChildren.push({ type: "id", value: rightSpan.id });
instances.set(rightSpan.id, rightSpan);
const tagProp: Prop = {
id: nanoid(),
instanceId: rightSpan.id,
type: "string",
name: "tag",
value: "span",
};
props.set(tagProp.id, tagProp);
}
parentInstance.children = newChildren;
return {
parentSelector: dropTarget.parentSelector,
position: newPosition,
};
};
export const getReparentDropTargetMutable = (
instances: Instances,
props: Props,
metas: Map<string, WsComponentMeta>,
dropTarget: DroppableTarget
): undefined | DroppableTarget => {
dropTarget = getCollectionDropTarget(instances, dropTarget) ?? dropTarget;
dropTarget =
getInstanceOrCreateFragmentIfNecessary(instances, dropTarget) ?? dropTarget;
dropTarget =
wrapEditableChildrenAroundDropTargetMutable(
instances,
props,
metas,
dropTarget
) ?? dropTarget;
return dropTarget;
};
export const cloneStyles = (
styles: Styles,
clonedStyleSourceIds: Map<Instance["id"], Instance["id"]>
) => {
const clonedStyles: StyleDecl[] = [];
for (const styleDecl of styles.values()) {
const styleSourceId = clonedStyleSourceIds.get(styleDecl.styleSourceId);
if (styleSourceId === undefined) {
continue;
}
clonedStyles.push({
...styleDecl,
styleSourceId,
});
}
return clonedStyles;
};
export const findLocalStyleSourcesWithinInstances = (
styleSources: IterableIterator<StyleSource> | StyleSource[],
styleSourceSelections:
| IterableIterator<StyleSourceSelection>
| StyleSourceSelection[],
instanceIds: Set<Instance["id"]>
) => {
const localStyleSourceIds = new Set<StyleSource["id"]>();
for (const styleSource of styleSources) {
if (styleSource.type === "local") {
localStyleSourceIds.add(styleSource.id);
}
}
const subtreeLocalStyleSourceIds = new Set<StyleSource["id"]>();
for (const { instanceId, values } of styleSourceSelections) {
// skip selections outside of subtree
if (instanceIds.has(instanceId) === false) {
continue;
}
// find only local style sources on selections
for (const styleSourceId of values) {
if (localStyleSourceIds.has(styleSourceId)) {
subtreeLocalStyleSourceIds.add(styleSourceId);
}
}
}
return subtreeLocalStyleSourceIds;
};