Files
webstudio/apps/builder/app/shared/tree-utils.ts
T
Bogdan Chadkin 3b8c58b435 refactor: use content model to detect rich text (#5109)
Ref https://github.com/webstudio-is/webstudio/issues/3632

Here I finally got rid from "rich-text-child" necessity. With html
elements in the tree it will no longer work so we need a new way to work
with rich text.

Added a few new utilities to content model which detect rich text by
text inside or any textual tags. Span and links can still be detected as
containers rather than children when not surrounded by text and other
textual elements.
2025-04-10 14:16:34 +07:00

281 lines
7.6 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";
import { isRichTextTree } from "./content-model";
// 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"][];
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
const isParentRichText = isRichTextTree({
instances,
props,
metas,
instanceId: parentId,
});
if (!isParentRichText) {
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;
};