Skip to content

Commit 28e61f4

Browse files
authored
experimental: add matching tag when insert element component (#5204)
Ref #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" https://github.com/user-attachments/assets/3ea87624-9524-4f2e-91b2-d658d16d7a1b
1 parent a04af45 commit 28e61f4

File tree

7 files changed

+271
-30
lines changed

7 files changed

+271
-30
lines changed

apps/builder/app/builder/features/components/components.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import {
4040
getComponentTemplateData,
4141
getInstanceLabel,
42+
insertWebstudioElementAt,
4243
insertWebstudioFragmentAt,
4344
} from "~/shared/instance-utils";
4445
import type { Publish } from "~/shared/pubsub";
@@ -215,9 +216,13 @@ export const ComponentsPanel = ({
215216
const [selectedComponent, setSelectedComponent] = useState<string>();
216217

217218
const handleInsert = (component: string) => {
218-
const fragment = getComponentTemplateData(component);
219-
if (fragment) {
220-
insertWebstudioFragmentAt(fragment);
219+
if (component === elementComponent) {
220+
insertWebstudioElementAt();
221+
} else {
222+
const fragment = getComponentTemplateData(component);
223+
if (fragment) {
224+
insertWebstudioFragmentAt(fragment);
225+
}
221226
}
222227
onClose();
223228
};

apps/builder/app/canvas/shared/use-drag-drop.ts

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useLayoutEffect, useRef } from "react";
2-
import type { Instance } from "@webstudio-is/sdk";
2+
import { elementComponent, type Instance } from "@webstudio-is/sdk";
33
import {
44
type Point,
55
useAutoScroll,
@@ -17,6 +17,7 @@ import {
1717
import { publish, useSubscribe } from "~/shared/pubsub";
1818
import {
1919
getComponentTemplateData,
20+
insertWebstudioElementAt,
2021
insertWebstudioFragmentAt,
2122
reparentInstance,
2223
} from "~/shared/instance-utils";
@@ -79,15 +80,20 @@ const findClosestDroppableInstanceSelector = (
7980
});
8081
let droppableIndex = -1;
8182
if (dragPayload?.type === "insert") {
82-
const fragment = getComponentTemplateData(dragPayload.dragComponent);
83-
if (fragment) {
84-
droppableIndex = findClosestInstanceMatchingFragment({
85-
instances,
86-
props,
87-
metas,
88-
instanceSelector,
89-
fragment,
90-
});
83+
// allow dropping element into any container
84+
if (dragPayload.dragComponent === elementComponent) {
85+
droppableIndex = 0;
86+
} else {
87+
const fragment = getComponentTemplateData(dragPayload.dragComponent);
88+
if (fragment) {
89+
droppableIndex = findClosestInstanceMatchingFragment({
90+
instances,
91+
props,
92+
metas,
93+
instanceSelector,
94+
fragment,
95+
});
96+
}
9197
}
9298
}
9399
if (dragPayload?.type === "reparent") {
@@ -316,23 +322,22 @@ export const useDragAndDrop = () => {
316322
const { dropTarget, dragPayload } = state.current;
317323

318324
if (dropTarget && dragPayload && isCanceled === false) {
325+
const insertable = {
326+
parentSelector: dropTarget.itemSelector,
327+
position: dropTarget.indexWithinChildren,
328+
};
319329
if (dragPayload.type === "insert") {
320-
const templateData = getComponentTemplateData(
321-
dragPayload.dragComponent
322-
);
323-
if (templateData === undefined) {
324-
return;
330+
if (dragPayload.dragComponent === elementComponent) {
331+
insertWebstudioElementAt(insertable);
332+
} else {
333+
const fragment = getComponentTemplateData(dragPayload.dragComponent);
334+
if (fragment) {
335+
insertWebstudioFragmentAt(fragment, insertable);
336+
}
325337
}
326-
insertWebstudioFragmentAt(templateData, {
327-
parentSelector: dropTarget.itemSelector,
328-
position: dropTarget.indexWithinChildren,
329-
});
330338
}
331339
if (dragPayload.type === "reparent") {
332-
reparentInstance(dragPayload.dragInstanceSelector, {
333-
parentSelector: dropTarget.itemSelector,
334-
position: dropTarget.indexWithinChildren,
335-
});
340+
reparentInstance(dragPayload.dragInstanceSelector, insertable);
336341
}
337342
}
338343

apps/builder/app/shared/instance-utils.test.tsx

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
insertInstanceChildrenMutable,
3636
findClosestInsertable,
3737
insertWebstudioFragmentAt,
38+
insertWebstudioElementAt,
3839
} from "./instance-utils";
3940
import {
4041
$assets,
@@ -51,7 +52,12 @@ import {
5152
$resources,
5253
} from "./nano-states";
5354
import { registerContainers } from "./sync";
54-
import { $awareness, getInstancePath, selectInstance } from "./awareness";
55+
import {
56+
$awareness,
57+
getInstancePath,
58+
selectInstance,
59+
selectPage,
60+
} from "./awareness";
5561

5662
enableMapSet();
5763
registerContainers();
@@ -339,6 +345,143 @@ describe("insert instance children", () => {
339345
});
340346
});
341347

348+
describe("insert webstudio element at", () => {
349+
beforeEach(() => {
350+
$styleSourceSelections.set(new Map());
351+
$styleSources.set(new Map());
352+
$breakpoints.set(new Map());
353+
$styles.set(new Map());
354+
$dataSources.set(new Map());
355+
$resources.set(new Map());
356+
$props.set(new Map());
357+
$assets.set(new Map());
358+
});
359+
360+
test("insert element with div tag into body", () => {
361+
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
362+
insertWebstudioElementAt({
363+
parentSelector: ["bodyId"],
364+
position: "end",
365+
});
366+
const [_bodyId, newInstanceId] = $instances.get().keys();
367+
expect($instances.get()).toEqual(
368+
renderData(
369+
<$.Body ws:id="bodyId">
370+
<ws.element ws:id={newInstanceId} ws:tag="div" />
371+
</$.Body>
372+
).instances
373+
);
374+
});
375+
376+
test("insert element with li tag into ul", () => {
377+
$instances.set(
378+
renderData(
379+
<$.Body ws:id="bodyId">
380+
<ws.element ws:id="listId" ws:tag="ul"></ws.element>
381+
</$.Body>
382+
).instances
383+
);
384+
insertWebstudioElementAt({
385+
parentSelector: ["listId", "bodyId"],
386+
position: "end",
387+
});
388+
const [_bodyId, _listId, newInstanceId] = $instances.get().keys();
389+
expect($instances.get()).toEqual(
390+
renderData(
391+
<$.Body ws:id="bodyId">
392+
<ws.element ws:id="listId" ws:tag="ul">
393+
<ws.element ws:id={newInstanceId} ws:tag="li" />
394+
</ws.element>
395+
</$.Body>
396+
).instances
397+
);
398+
});
399+
400+
test("insert element into selected instance", () => {
401+
$pages.set(
402+
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
403+
);
404+
$instances.set(
405+
renderData(
406+
<$.Body ws:id="bodyId">
407+
<ws.element ws:id="divId" ws:tag="div"></ws.element>
408+
</$.Body>
409+
).instances
410+
);
411+
selectPage("homePageId");
412+
selectInstance(["divId", "bodyId"]);
413+
insertWebstudioElementAt();
414+
const [_bodyId, _divId, newInstanceId] = $instances.get().keys();
415+
expect($instances.get()).toEqual(
416+
renderData(
417+
<$.Body ws:id="bodyId">
418+
<ws.element ws:id="divId" ws:tag="div">
419+
<ws.element ws:id={newInstanceId} ws:tag="div" />
420+
</ws.element>
421+
</$.Body>
422+
).instances
423+
);
424+
});
425+
426+
test("insert element into closest non-textual container", () => {
427+
$pages.set(
428+
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
429+
);
430+
$instances.set(
431+
renderData(
432+
<$.Body ws:id="bodyId">
433+
<ws.element ws:id="divId" ws:tag="div">
434+
text
435+
</ws.element>
436+
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
437+
</$.Body>
438+
).instances
439+
);
440+
selectPage("homePageId");
441+
selectInstance(["divId", "bodyId"]);
442+
insertWebstudioElementAt();
443+
const [_bodyId, _divId, _spanId, newInstanceId] = $instances.get().keys();
444+
expect($instances.get()).toEqual(
445+
renderData(
446+
<$.Body ws:id="bodyId">
447+
<ws.element ws:id="divId" ws:tag="div">
448+
text
449+
</ws.element>
450+
<ws.element ws:id={newInstanceId} ws:tag="div" />
451+
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
452+
</$.Body>
453+
).instances
454+
);
455+
});
456+
457+
test("insert element into closest non-empty container", () => {
458+
$pages.set(
459+
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
460+
);
461+
$instances.set(
462+
renderData(
463+
<$.Body ws:id="bodyId">
464+
<ws.element ws:id="imgId" ws:tag="img"></ws.element>
465+
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
466+
</$.Body>
467+
).instances
468+
);
469+
selectPage("homePageId");
470+
selectInstance(["imgId", "bodyId"]);
471+
insertWebstudioElementAt();
472+
const [_bodyId, _imgId, _spanId, newInstanceId] = $instances.get().keys();
473+
expect($instances.get()).toEqual(
474+
renderData(
475+
<$.Body ws:id="bodyId">
476+
<ws.element ws:id="imgId" ws:tag="img"></ws.element>
477+
<ws.element ws:id={newInstanceId} ws:tag="div" />
478+
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
479+
</$.Body>
480+
).instances
481+
);
482+
});
483+
});
484+
342485
describe("insert webstudio fragment at", () => {
343486
beforeEach(() => {
344487
$styleSourceSelections.set(new Map());

apps/builder/app/shared/instance-utils.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
parseComponentName,
3232
Props,
3333
elementComponent,
34+
tags,
3435
} from "@webstudio-is/sdk";
3536
import {
3637
$props,
@@ -62,6 +63,7 @@ import { setDifference, setUnion } from "./shim";
6263
import { breakCyclesMutable, findCycles } from "@webstudio-is/project-build";
6364
import {
6465
$awareness,
66+
$selectedInstancePath,
6567
$selectedPage,
6668
getInstancePath,
6769
selectInstance,
@@ -76,6 +78,7 @@ import {
7678
import {
7779
findClosestNonTextualContainer,
7880
isRichTextTree,
81+
isTreeSatisfyingContentModel,
7982
} from "./content-model";
8083
import type { Project } from "@webstudio-is/project";
8184

@@ -258,6 +261,80 @@ export const insertInstanceChildrenMutable = (
258261
}
259262
};
260263

264+
export const insertWebstudioElementAt = (insertable?: Insertable) => {
265+
const instances = $instances.get();
266+
const props = $props.get();
267+
const metas = $registeredComponentMetas.get();
268+
// find closest container and try to match new element with it
269+
if (insertable === undefined) {
270+
const instancePath = $selectedInstancePath.get();
271+
if (instancePath === undefined) {
272+
return false;
273+
}
274+
const [{ instanceSelector }] = instancePath;
275+
const containerSelector = findClosestNonTextualContainer({
276+
instances,
277+
props,
278+
metas,
279+
instanceSelector,
280+
});
281+
const insertableIndex = instanceSelector.length - containerSelector.length;
282+
if (insertableIndex === 0) {
283+
insertable = {
284+
parentSelector: containerSelector,
285+
position: "end",
286+
};
287+
} else {
288+
const containerInstance = instances.get(containerSelector[0]);
289+
if (containerInstance === undefined) {
290+
return false;
291+
}
292+
const lastChildInstanceId = instanceSelector[insertableIndex - 1];
293+
const lastChildPosition = containerInstance.children.findIndex(
294+
(child) => child.type === "id" && child.value === lastChildInstanceId
295+
);
296+
insertable = {
297+
parentSelector: containerSelector,
298+
position: lastChildPosition + 1,
299+
};
300+
}
301+
}
302+
// create element and find matching tag
303+
const element: Instance = {
304+
type: "instance",
305+
id: nanoid(),
306+
component: elementComponent,
307+
children: [],
308+
};
309+
const newInstances = new Map(instances);
310+
newInstances.set(element.id, element);
311+
let matchingTag: undefined | string;
312+
for (const tag of tags) {
313+
element.tag = tag;
314+
const isSatisfying = isTreeSatisfyingContentModel({
315+
instances: newInstances,
316+
props,
317+
metas,
318+
instanceSelector: [element.id, ...insertable.parentSelector],
319+
});
320+
if (isSatisfying) {
321+
matchingTag = tag;
322+
break;
323+
}
324+
}
325+
if (matchingTag === undefined) {
326+
return false;
327+
}
328+
// insert element
329+
updateWebstudioData((data) => {
330+
data.instances.set(element.id, element);
331+
const children: Instance["children"] = [{ type: "id", value: element.id }];
332+
insertInstanceChildrenMutable(data, children, insertable);
333+
});
334+
selectInstance([element.id, ...insertable.parentSelector]);
335+
return true;
336+
};
337+
261338
export const insertWebstudioFragmentAt = (
262339
fragment: WebstudioFragment,
263340
insertable?: Insertable

packages/html-data/bin/elements.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,17 @@ for (const [tag, element] of Object.entries(elementsByTag)) {
7676
}
7777
tags.push(tag);
7878
}
79+
const getTagScore = (tag: string) => {
80+
if (tag === "div") {
81+
return 20;
82+
}
83+
if (tag === "span") {
84+
return 10;
85+
}
86+
return 0;
87+
};
88+
// put div and span first
89+
tags.sort((left, right) => getTagScore(right) - getTagScore(left));
7990
const tagsContent = `export const tags: string[] = ${JSON.stringify(tags, null, 2)};
8091
`;
8192
const tagsFile = "../sdk/src/__generated__/tags.ts";

0 commit comments

Comments
 (0)