From 4989b4a8ce398f4b19717d084cf3d9033038b858 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Mon, 12 May 2025 22:05:01 +0400 Subject: [PATCH 1/2] experimental: add matching tag when insert element component Ref https://github.com/webstudio-is/webstudio/issues/3632 Now element does not provide default tag and instead builder will try to find tag matching its parent. This way we can insert element, give it "ul" tag and then insert another element into it which will match only "li" --- .../features/components/components.tsx | 11 +- .../app/canvas/shared/use-drag-drop.ts | 51 +++--- .../app/shared/instance-utils.test.tsx | 145 +++++++++++++++++- apps/builder/app/shared/instance-utils.ts | 77 ++++++++++ packages/html-data/bin/elements.ts | 11 ++ packages/sdk/src/__generated__/tags.ts | 4 +- packages/sdk/src/core-templates.tsx | 2 +- 7 files changed, 271 insertions(+), 30 deletions(-) diff --git a/apps/builder/app/builder/features/components/components.tsx b/apps/builder/app/builder/features/components/components.tsx index 0dd04bd9c883..2016712e9c55 100644 --- a/apps/builder/app/builder/features/components/components.tsx +++ b/apps/builder/app/builder/features/components/components.tsx @@ -39,6 +39,7 @@ import { import { getComponentTemplateData, getInstanceLabel, + insertWebstudioElementAt, insertWebstudioFragmentAt, } from "~/shared/instance-utils"; import type { Publish } from "~/shared/pubsub"; @@ -215,9 +216,13 @@ export const ComponentsPanel = ({ const [selectedComponent, setSelectedComponent] = useState(); const handleInsert = (component: string) => { - const fragment = getComponentTemplateData(component); - if (fragment) { - insertWebstudioFragmentAt(fragment); + if (component === elementComponent) { + insertWebstudioElementAt(); + } else { + const fragment = getComponentTemplateData(component); + if (fragment) { + insertWebstudioFragmentAt(fragment); + } } onClose(); }; diff --git a/apps/builder/app/canvas/shared/use-drag-drop.ts b/apps/builder/app/canvas/shared/use-drag-drop.ts index aa7a1ff1d230..66061dd95a54 100644 --- a/apps/builder/app/canvas/shared/use-drag-drop.ts +++ b/apps/builder/app/canvas/shared/use-drag-drop.ts @@ -1,5 +1,5 @@ import { useLayoutEffect, useRef } from "react"; -import type { Instance } from "@webstudio-is/sdk"; +import { elementComponent, type Instance } from "@webstudio-is/sdk"; import { type Point, useAutoScroll, @@ -17,6 +17,7 @@ import { import { publish, useSubscribe } from "~/shared/pubsub"; import { getComponentTemplateData, + insertWebstudioElementAt, insertWebstudioFragmentAt, reparentInstance, } from "~/shared/instance-utils"; @@ -79,15 +80,20 @@ const findClosestDroppableInstanceSelector = ( }); let droppableIndex = -1; if (dragPayload?.type === "insert") { - const fragment = getComponentTemplateData(dragPayload.dragComponent); - if (fragment) { - droppableIndex = findClosestInstanceMatchingFragment({ - instances, - props, - metas, - instanceSelector, - fragment, - }); + // allow dropping element into any container + if (dragPayload.dragComponent === elementComponent) { + droppableIndex = 0; + } else { + const fragment = getComponentTemplateData(dragPayload.dragComponent); + if (fragment) { + droppableIndex = findClosestInstanceMatchingFragment({ + instances, + props, + metas, + instanceSelector, + fragment, + }); + } } } if (dragPayload?.type === "reparent") { @@ -316,23 +322,22 @@ export const useDragAndDrop = () => { const { dropTarget, dragPayload } = state.current; if (dropTarget && dragPayload && isCanceled === false) { + const insertable = { + parentSelector: dropTarget.itemSelector, + position: dropTarget.indexWithinChildren, + }; if (dragPayload.type === "insert") { - const templateData = getComponentTemplateData( - dragPayload.dragComponent - ); - if (templateData === undefined) { - return; + if (dragPayload.dragComponent === elementComponent) { + insertWebstudioElementAt(insertable); + } else { + const fragment = getComponentTemplateData(dragPayload.dragComponent); + if (fragment) { + insertWebstudioFragmentAt(fragment, insertable); + } } - insertWebstudioFragmentAt(templateData, { - parentSelector: dropTarget.itemSelector, - position: dropTarget.indexWithinChildren, - }); } if (dragPayload.type === "reparent") { - reparentInstance(dragPayload.dragInstanceSelector, { - parentSelector: dropTarget.itemSelector, - position: dropTarget.indexWithinChildren, - }); + reparentInstance(dragPayload.dragInstanceSelector, insertable); } } diff --git a/apps/builder/app/shared/instance-utils.test.tsx b/apps/builder/app/shared/instance-utils.test.tsx index e04b6e97f2a8..cc2ebf23052b 100644 --- a/apps/builder/app/shared/instance-utils.test.tsx +++ b/apps/builder/app/shared/instance-utils.test.tsx @@ -35,6 +35,7 @@ import { insertInstanceChildrenMutable, findClosestInsertable, insertWebstudioFragmentAt, + insertWebstudioElementAt, } from "./instance-utils"; import { $assets, @@ -51,7 +52,12 @@ import { $resources, } from "./nano-states"; import { registerContainers } from "./sync"; -import { $awareness, getInstancePath, selectInstance } from "./awareness"; +import { + $awareness, + getInstancePath, + selectInstance, + selectPage, +} from "./awareness"; enableMapSet(); registerContainers(); @@ -339,6 +345,143 @@ describe("insert instance children", () => { }); }); +describe("insert webstudio element at", () => { + beforeEach(() => { + $styleSourceSelections.set(new Map()); + $styleSources.set(new Map()); + $breakpoints.set(new Map()); + $styles.set(new Map()); + $dataSources.set(new Map()); + $resources.set(new Map()); + $props.set(new Map()); + $assets.set(new Map()); + }); + + test("insert element with div tag into body", () => { + $instances.set(renderData(<$.Body ws:id="bodyId">).instances); + insertWebstudioElementAt({ + parentSelector: ["bodyId"], + position: "end", + }); + const [_bodyId, newInstanceId] = $instances.get().keys(); + expect($instances.get()).toEqual( + renderData( + <$.Body ws:id="bodyId"> + + + ).instances + ); + }); + + test("insert element with li tag into ul", () => { + $instances.set( + renderData( + <$.Body ws:id="bodyId"> + + + ).instances + ); + insertWebstudioElementAt({ + parentSelector: ["listId", "bodyId"], + position: "end", + }); + const [_bodyId, _listId, newInstanceId] = $instances.get().keys(); + expect($instances.get()).toEqual( + renderData( + <$.Body ws:id="bodyId"> + + + + + ).instances + ); + }); + + test("insert element into selected instance", () => { + $pages.set( + createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) + ); + $instances.set( + renderData( + <$.Body ws:id="bodyId"> + + + ).instances + ); + selectPage("homePageId"); + selectInstance(["divId", "bodyId"]); + insertWebstudioElementAt(); + const [_bodyId, _divId, newInstanceId] = $instances.get().keys(); + expect($instances.get()).toEqual( + renderData( + <$.Body ws:id="bodyId"> + + + + + ).instances + ); + }); + + test("insert element into closest non-textual container", () => { + $pages.set( + createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) + ); + $instances.set( + renderData( + <$.Body ws:id="bodyId"> + + text + + + + ).instances + ); + selectPage("homePageId"); + selectInstance(["divId", "bodyId"]); + insertWebstudioElementAt(); + const [_bodyId, _divId, _spanId, newInstanceId] = $instances.get().keys(); + expect($instances.get()).toEqual( + renderData( + <$.Body ws:id="bodyId"> + + text + + + + + ).instances + ); + }); + + test("insert element into closest non-empty container", () => { + $pages.set( + createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) + ); + $instances.set( + renderData( + <$.Body ws:id="bodyId"> + + + + ).instances + ); + selectPage("homePageId"); + selectInstance(["imgId", "bodyId"]); + insertWebstudioElementAt(); + const [_bodyId, _imgId, _spanId, newInstanceId] = $instances.get().keys(); + expect($instances.get()).toEqual( + renderData( + <$.Body ws:id="bodyId"> + + + + + ).instances + ); + }); +}); + describe("insert webstudio fragment at", () => { beforeEach(() => { $styleSourceSelections.set(new Map()); diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index bfc554072349..2791b7cd25ca 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -31,6 +31,7 @@ import { parseComponentName, Props, elementComponent, + tags, } from "@webstudio-is/sdk"; import { $props, @@ -62,6 +63,7 @@ import { setDifference, setUnion } from "./shim"; import { breakCyclesMutable, findCycles } from "@webstudio-is/project-build"; import { $awareness, + $selectedInstancePath, $selectedPage, getInstancePath, selectInstance, @@ -76,6 +78,7 @@ import { import { findClosestNonTextualContainer, isRichTextTree, + isTreeSatisfyingContentModel, } from "./content-model"; import type { Project } from "@webstudio-is/project"; @@ -258,6 +261,80 @@ export const insertInstanceChildrenMutable = ( } }; +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 isMatching = isTreeSatisfyingContentModel({ + instances: newInstances, + props, + metas, + instanceSelector: [element.id, ...insertable.parentSelector], + }); + if (isMatching) { + 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 diff --git a/packages/html-data/bin/elements.ts b/packages/html-data/bin/elements.ts index 525380c70280..d6fb3d4e6d59 100644 --- a/packages/html-data/bin/elements.ts +++ b/packages/html-data/bin/elements.ts @@ -76,6 +76,17 @@ for (const [tag, element] of Object.entries(elementsByTag)) { } tags.push(tag); } +const getTagScore = (tag: string) => { + if (tag === "div") { + return 20; + } + if (tag === "span") { + return 10; + } + return 0; +}; +// put div and span first +tags.sort((left, right) => getTagScore(right) - getTagScore(left)); const tagsContent = `export const tags: string[] = ${JSON.stringify(tags, null, 2)}; `; const tagsFile = "../sdk/src/__generated__/tags.ts"; diff --git a/packages/sdk/src/__generated__/tags.ts b/packages/sdk/src/__generated__/tags.ts index eee4408144e5..9a23397251dd 100644 --- a/packages/sdk/src/__generated__/tags.ts +++ b/packages/sdk/src/__generated__/tags.ts @@ -1,4 +1,6 @@ export const tags: string[] = [ + "div", + "span", "a", "abbr", "address", @@ -26,7 +28,6 @@ export const tags: string[] = [ "details", "dfn", "dialog", - "div", "dl", "dt", "em", @@ -83,7 +84,6 @@ export const tags: string[] = [ "slot", "small", "source", - "span", "strong", "sub", "summary", diff --git a/packages/sdk/src/core-templates.tsx b/packages/sdk/src/core-templates.tsx index a437a00b17b9..b368569373f7 100644 --- a/packages/sdk/src/core-templates.tsx +++ b/packages/sdk/src/core-templates.tsx @@ -18,7 +18,7 @@ const elementMeta: TemplateMeta = { order: 0, description: "An HTML element is a core building block for web pages, structuring and displaying content like text, images, and links.", - template: , + template: , }; const collectionItem = new Parameter("collectionItem"); From 565d1b2d29938c97339d4ae9601040cfb5d00a33 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Tue, 13 May 2025 11:00:02 +0400 Subject: [PATCH 2/2] matching -> satisfying --- apps/builder/app/shared/instance-utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index 2791b7cd25ca..6528d22b7089 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -311,13 +311,13 @@ export const insertWebstudioElementAt = (insertable?: Insertable) => { let matchingTag: undefined | string; for (const tag of tags) { element.tag = tag; - const isMatching = isTreeSatisfyingContentModel({ + const isSatisfying = isTreeSatisfyingContentModel({ instances: newInstances, props, metas, instanceSelector: [element.id, ...insertable.parentSelector], }); - if (isMatching) { + if (isSatisfying) { matchingTag = tag; break; }