Refactor closest droppable with instances index (#945)

Ref https://github.com/webstudio-is/webstudio-designer/issues/696

A lot of our utilities can reuse indexed instances instead of
retraversing tree. This will also simplify migration to normalized
instances.

Here I refactored droppable instance lookup.
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-02-06 14:16:52 +03:00
1 parent 49e82f8f2d
commit 052d049073
8 files changed
+182 -168

No files matched your search

@@ -1,69 +0,0 @@
import { describe, test, expect } from "@jest/globals";
import type { Instance } from "@webstudio-is/project-build";
import { findInsertLocation } from "./instance";
const tree: Instance = {
type: "instance",
component: "Body",
id: "root",
children: [
{
type: "instance",
component: "Heading",
id: "heading1",
children: [],
},
{
type: "instance",
component: "Box",
id: "box1",
children: [
{
type: "instance",
component: "Box",
id: "box2",
children: [],
},
{
type: "instance",
component: "Heading",
id: "heading2",
children: [],
},
{
type: "instance",
component: "Heading",
id: "heading3",
children: [],
},
],
},
],
};
describe("findInsertLocation", () => {
test("if no selected instance, insert into root", () => {
expect(findInsertLocation(tree, undefined)).toEqual({
parentId: "root",
position: "end",
});
});
test("if selected instance can accept children insert into it", () => {
expect(findInsertLocation(tree, "box1")).toEqual({
parentId: "box1",
position: "end",
});
});
test("if selected instance can not accept children insert into parent", () => {
expect(findInsertLocation(tree, "heading1")).toEqual({
parentId: "root",
position: 1,
});
expect(findInsertLocation(tree, "heading2")).toEqual({
parentId: "box1",
position: 2,
});
});
});
+4 -42
View File
@@ -1,8 +1,7 @@
import { useEffect } from "react";
import store from "immerhin";
import type { Instance } from "@webstudio-is/project-build";
import { getComponentMeta } from "@webstudio-is/react-sdk";
import { utils, type InstanceInsertionSpec } from "@webstudio-is/project";
import { utils } from "@webstudio-is/project";
import { useSubscribe } from "~/shared/pubsub";
import {
rootInstanceContainer,
@@ -16,57 +15,20 @@ declare module "~/shared/pubsub" {
textEditingInstanceId?: Instance["id"];
insertInstance: {
instance: Instance;
dropTarget?: { parentId: Instance["id"]; position: number };
dropTarget?: { parentId: Instance["id"]; position: number | "end" };
};
}
}
export const findInsertLocation = (
rootInstance: Instance,
selectedInstanceId: Instance["id"] | undefined
): InstanceInsertionSpec => {
if (selectedInstanceId === undefined) {
return { parentId: rootInstance.id, position: "end" };
}
const path = utils.tree.getInstancePathWithPositions(
rootInstance,
selectedInstanceId
);
path.reverse();
const parentIndex = path.findIndex(({ item }) => {
const meta = getComponentMeta(item.component);
return meta?.type === "body" || meta?.type === "container";
});
// Just in case selected Instance is not in the tree for some reason.
if (parentIndex === -1) {
return { parentId: rootInstance.id, position: "end" };
}
return {
parentId: path[parentIndex].item.id,
position: parentIndex === 0 ? "end" : path[parentIndex - 1].position + 1,
};
};
export const useInsertInstance = () => {
useSubscribe("insertInstance", ({ instance, dropTarget }) => {
const selectedInstanceId = selectedInstanceIdStore.get();
store.createTransaction([rootInstanceContainer], (rootInstance) => {
if (rootInstance === undefined) {
return;
}
const hasInserted = utils.tree.insertInstanceMutable(
rootInstance,
instance,
dropTarget ?? findInsertLocation(rootInstance, selectedInstanceId)
);
if (hasInserted) {
selectedInstanceIdStore.set(instance.id);
}
utils.tree.insertInstanceMutable(rootInstance, instance, dropTarget);
});
selectedInstanceIdStore.set(instance.id);
});
};
@@ -9,6 +9,11 @@ import {
import { theme, Flex, useDrag, type Point } from "@webstudio-is/design-system";
import { PlusIcon } from "@webstudio-is/icons";
import { utils } from "@webstudio-is/project";
import { findClosestDroppableTarget } from "~/shared/tree-utils";
import {
instancesIndexStore,
selectedInstanceIdStore,
} from "~/shared/nano-states";
import { useSubscribe, type Publish } from "~/shared/pubsub";
import { useCanvasRect, useZoom } from "~/designer/shared/nano-states";
import type { TabName } from "../../types";
@@ -168,9 +173,13 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
const instance = utils.tree.createInstance({
component,
});
const dropTarget = findClosestDroppableTarget(
instancesIndexStore.get(),
selectedInstanceIdStore.get()
);
publish({
type: "insertInstance",
payload: { instance },
payload: { instance, dropTarget },
});
}}
/>
@@ -9,7 +9,6 @@ import {
StyleSourceSelections,
} from "@webstudio-is/project-build";
import { utils } from "@webstudio-is/project";
import { findInsertLocation } from "~/canvas/shared/instance";
import {
rootInstanceContainer,
propsStore,
@@ -17,6 +16,7 @@ import {
selectedInstanceIdStore,
styleSourceSelectionsStore,
styleSourcesStore,
instancesIndexStore,
} from "../nano-states";
import {
cloneInstance,
@@ -24,6 +24,7 @@ import {
cloneStyles,
cloneStyleSources,
cloneStyleSourceSelections,
findClosestDroppableTarget,
findSubtree,
findSubtreeLocalStyleSources,
} from "../tree-utils";
@@ -89,7 +90,10 @@ const copyInstanceData = (targetInstanceId: string) => {
};
const pasteInstance = (data: InstanceData) => {
const selectedInstanceId = selectedInstanceIdStore.get();
const dropTarget = findClosestDroppableTarget(
instancesIndexStore.get(),
selectedInstanceIdStore.get()
);
store.createTransaction(
[
rootInstanceContainer,
@@ -102,14 +106,7 @@ const pasteInstance = (data: InstanceData) => {
if (rootInstance === undefined) {
return;
}
const hasInserted = utils.tree.insertInstanceMutable(
rootInstance,
data.instance,
findInsertLocation(rootInstance, selectedInstanceId)
);
if (hasInserted === false) {
return;
}
utils.tree.insertInstanceMutable(rootInstance, data.instance, dropTarget);
// append without checking existing
// because all data already cloned with new ids
props.push(...data.props);
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import { atom, computed, type WritableAtom } from "nanostores";
import { useStore } from "@nanostores/react";
import type { AuthPermit } from "@webstudio-is/trpc-interface";
import type { Asset } from "@webstudio-is/asset-uploader";
import type {
Instance,
@@ -21,7 +22,7 @@ import type {
} from "~/designer/shared/assets";
import { useSyncInitializeOnce } from "../hook-utils";
import { shallowComputed } from "../store-utils";
import type { AuthPermit } from "@webstudio-is/trpc-interface";
import { createInstancesIndex } from "../tree-utils";
const useValue = <T>(atom: WritableAtom<T>) => {
const value = useStore(atom);
@@ -37,23 +38,7 @@ export const useSetRootInstance = (root: Instance) => {
};
export const instancesIndexStore = computed(
rootInstanceContainer,
(rootInstance) => {
const instancesById = new Map<Instance["id"], Instance>();
const traverseInstances = (instance: Instance) => {
instancesById.set(instance.id, instance);
for (const child of instance.children) {
if (child.type === "instance") {
traverseInstances(child);
}
}
};
if (rootInstance !== undefined) {
traverseInstances(rootInstance);
}
return {
instancesById,
};
}
createInstancesIndex
);
export const propsStore = atom<Props>([]);
+79 -27
View File
@@ -12,17 +12,23 @@ import {
cloneStyles,
cloneStyleSources,
cloneStyleSourceSelections,
createInstancesIndex,
findClosestDroppableTarget,
findSubtree,
findSubtreeLocalStyleSources,
} from "./tree-utils";
const expectString = expect.any(String) as unknown as string;
const createInstance = (id: Instance["id"], children: Instance[]): Instance => {
const createInstance = (
id: Instance["id"],
component: string,
children: Instance[]
): Instance => {
return {
type: "instance",
id,
component: "Box",
component,
children: children,
};
};
@@ -77,31 +83,75 @@ const createStyleDecl = (styleSourceId: string): StyleDecl => {
};
};
test("find closest droppable target", () => {
const rootInstance = createInstance("root", "Body", [
createInstance("box1", "Box", [
createInstance("box11", "Box", []),
createInstance("box12", "Box", []),
createInstance("box13", "Box", []),
]),
createInstance("box2", "Box", [
createInstance("paragraph21", "Paragraph", [
createInstance("bold", "Bold", []),
]),
createInstance("box22", "Box", []),
]),
createInstance("box3", "Box", [
createInstance("box31", "Box", []),
createInstance("box32", "Box", []),
createInstance("box33", "Box", []),
]),
]);
const instancesIndex = createInstancesIndex(rootInstance);
expect(findClosestDroppableTarget(instancesIndex, "bold")).toEqual({
parentId: "box2",
position: 1,
});
expect(findClosestDroppableTarget(instancesIndex, "box3")).toEqual({
parentId: "box3",
position: 3,
});
expect(findClosestDroppableTarget(instancesIndex, "root")).toEqual({
parentId: "root",
position: 3,
});
expect(findClosestDroppableTarget(instancesIndex, undefined)).toEqual({
parentId: "root",
position: 3,
});
});
test("find subtree with all descendants and parent instance", () => {
const rootInstance: Instance = createInstance("root", [
createInstance("box1", []),
createInstance("box2", [
createInstance("box3", [
createInstance("child1", []),
createInstance("child2", [createInstance("descendant", [])]),
createInstance("child3", []),
const rootInstance: Instance = createInstance("root", "Box", [
createInstance("box1", "Box", []),
createInstance("box2", "Box", [
createInstance("box3", "Box", [
createInstance("child1", "Box", []),
createInstance("child2", "Box", [
createInstance("descendant", "Box", []),
]),
createInstance("child3", "Box", []),
]),
]),
createInstance("box4", []),
createInstance("box4", "Box", []),
]);
expect(findSubtree(rootInstance, "box3")).toEqual({
parentInstance: createInstance("box2", [
createInstance("box3", [
createInstance("child1", []),
createInstance("child2", [createInstance("descendant", [])]),
createInstance("child3", []),
parentInstance: createInstance("box2", "Box", [
createInstance("box3", "Box", [
createInstance("child1", "Box", []),
createInstance("child2", "Box", [
createInstance("descendant", "Box", []),
]),
createInstance("child3", "Box", []),
]),
]),
targetInstance: createInstance("box3", [
createInstance("child1", []),
createInstance("child2", [createInstance("descendant", [])]),
createInstance("child3", []),
targetInstance: createInstance("box3", "Box", [
createInstance("child1", "Box", []),
createInstance("child2", "Box", [
createInstance("descendant", "Box", []),
]),
createInstance("child3", "Box", []),
]),
subtreeIds: new Set(["box3", "child1", "child2", "child3", "descendant"]),
});
@@ -116,17 +166,19 @@ test("find subtree with all descendants and parent instance", () => {
});
test("clone instance tree and provide cloned ids map", () => {
const instance = createInstance("box", [
createInstance("child1", []),
createInstance("child2", [createInstance("descendant", [])]),
createInstance("child3", []),
const instance = createInstance("box", "Box", [
createInstance("child1", "Box", []),
createInstance("child2", "Box", [createInstance("descendant", "Box", [])]),
createInstance("child3", "Box", []),
]);
const { clonedInstance, clonedInstanceIds } = cloneInstance(instance);
expect(clonedInstance).toEqual(
createInstance(expectString, [
createInstance(expectString, []),
createInstance(expectString, [createInstance(expectString, [])]),
createInstance(expectString, []),
createInstance(expectString, "Box", [
createInstance(expectString, "Box", []),
createInstance(expectString, "Box", [
createInstance(expectString, "Box", []),
]),
createInstance(expectString, "Box", []),
])
);
expect(clonedInstanceIds.get(instance.id)).toEqual(clonedInstance.id);
+75
View File
@@ -8,6 +8,7 @@ import type {
StyleSources,
StyleSourceSelections,
} from "@webstudio-is/project-build";
import { getComponentMeta } from "@webstudio-is/react-sdk";
const traverseInstances = (
instance: Instance,
@@ -24,6 +25,80 @@ const traverseInstances = (
}
};
type InstancesIndex = {
rootInstanceId: undefined | Instance["id"];
instancesById: Map<Instance["id"], Instance>;
parentInstancesById: Map<Instance["id"], Instance>;
};
export const createInstancesIndex = (
rootInstance: undefined | Instance
): InstancesIndex => {
const instancesById = new Map<Instance["id"], Instance>();
const parentInstancesById = new Map<Instance["id"], Instance>();
if (rootInstance) {
// traverse skips root without parent
instancesById.set(rootInstance.id, rootInstance);
traverseInstances(rootInstance, (child, parent) => {
parentInstancesById.set(child.id, parent);
instancesById.set(child.id, child);
});
}
return {
rootInstanceId: rootInstance?.id,
instancesById,
parentInstancesById,
};
};
const isInstanceDroppable = (instance: Instance) => {
const meta = getComponentMeta(instance.component);
return meta?.type === "body" || meta?.type === "container";
};
type DroppableTarget = {
parentId: Instance["id"];
position: number;
};
export const findClosestDroppableTarget = (
instancesIndex: InstancesIndex,
instanceId: undefined | Instance["id"]
): undefined | DroppableTarget => {
const { instancesById, parentInstancesById } = instancesIndex;
if (instancesIndex.rootInstanceId === undefined) {
return;
}
// fallback to root instance
let droppableInstance = instancesById.get(
instanceId ?? instancesIndex.rootInstanceId
);
if (droppableInstance === undefined) {
return;
}
let position = -1;
while (isInstanceDroppable(droppableInstance) === false) {
const parentInstance = parentInstancesById.get(droppableInstance.id);
if (parentInstance === undefined) {
break;
}
// source of lookup in children
const sourceInstanceId = droppableInstance.id;
droppableInstance = parentInstance;
position = droppableInstance.children.findIndex(
(child) => child.type === "instance" && child.id === sourceInstanceId
);
}
return {
parentId: droppableInstance.id,
// put in the end when no position provided
position:
position === -1 ? droppableInstance.children.length : position + 1,
};
};
export const findSubtree = (
rootInstance: Instance,
targetInstanceId: Instance["id"]
@@ -8,8 +8,11 @@ export type InstanceInsertionSpec = {
export const insertInstanceMutable = (
rootInstance: Instance,
instance: Instance,
spec: InstanceInsertionSpec
spec: undefined | InstanceInsertionSpec
): boolean => {
if (spec === undefined) {
return false;
}
if (spec.parentId !== rootInstance.id) {
for (const child of rootInstance.children) {
if (child.type === "text") {