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..6528d22b7089 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 isSatisfying = isTreeSatisfyingContentModel({ + instances: newInstances, + props, + metas, + instanceSelector: [element.id, ...insertable.parentSelector], + }); + if (isSatisfying) { + 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");