Problem/Motivation
The Builder island renders each component for real, inside its iframe, and currently wraps the content of every slot in a display_builder:dropzone element (<db-dropzone class="db-dropzone">) to serve as the SortableJS drop container.
That wrapper is an extra box between a component and its own slot content, and it breaks the rendered layout in several ways:
- a slot rendered inside a
display: grid/flexcontainer no longer has the component template's element as the direct child, so grid/flex item semantics,gapand:nth-child()selectors stop applying; - the wrapper's own
min-width/min-height(idle, and larger while dragging or highlighting) can grow ancestors; - because SortableJS needs that box to work, the wrapper could not simply be hidden or
display: contents(SortableJS measures the container box; see SortableJS #1853).
Steps to reproduce
- Build a page whose component renders slot content inside a grid or flex container (e.g. a hero with a
gridslot, or a list rendered from a slot). - Open the Builder and compare the iframe rendering with the front-end output: the slot content sits one level deeper than the template intends, and the grid/flex layout of the component is wrong.
- Enable "Drop zones" in the Highlight menu: the extra min-height/padding makes it worse.
Proposed resolution
Stop wrapping the Builder's slot content. The slot marker becomes a pair of HTML comments carrying a base64 payload (builder id, node id, slot id, title, the data-hx-post URL, max items), rendered around the slot content by a BuilderPanel::buildComponentSlot() override:
<nav class="menu"> <!-- the component's real slot container, untouched --> <!--db-slot:eyJidWlsZGVySWQi…--> <li>…</li><li>…</li> <!-- content: still the container's direct children --> <!--/db-slot-->
A comment generates no box at all — it cannot affect grid/flex layout, :nth-child() or gap — and it cannot trip the early tag-closing that keeps dropzone.twig on a custom element. The payload is base64 so no -- sequence lands inside the comment, and it is rendered through Markup::create() because Xss::filter strips comments from plain #markup.
On the client, the builder page walks the iframe document for the markers once per refresh (annotateSlots() in builder_frame.js), decodes each payload and stamps the slot attributes onto the comment's parent — the component's real slot container — which then acts as the dropzone:
- Sortable instances live on the real containers (plus the unchanged root dropzone); insert position, locks and the cross-iframe hit test count members (direct children carrying their own
data-node-id), not children, since a container may host the component's own chrome next to the slot content; - a container hosting several slots keeps every payload; the insert position resolves to the slot whose stretch of children it falls into, and the drop POSTs to that slot's URL with an index within it;
- outline, ghost tint and slot labels apply to containers during drag/highlight states only, and
contain: layoutanchors each label at its own corner without touching the container's positioning.
The side panels (Scaffold cards, Navigator tree, library) keep the wrapped dropzone: their DOM is the builder's own, there is no real page layout to protect. The root dropzone is unchanged.
While testing
Selecting a component that renders as <a href=""> (a button with no configured URL) navigated the Builder iframe to its own URL — a whole reload, scroll lost, annotations gone. The click handler in builder_frame.js skipped preventDefault() for an empty href; it now keeps every link from navigating except pure # anchors. This is independent of the wrapper change and can be split into its own issue if preferred.
Remaining tasks
- Full
@basePlaywright run on a configured harness (the one Builder-scoped selector is updated in this patch; Navigator/library/tree specs keep the wrapped dropzone and are untouched). - Follow-up: an automated test for the multi-slot container routing.
User interface changes
The Builder's editing canvas now renders components exactly as the front end does (only HTML comments and data-* attributes added at runtime). Highlight/drag chrome on slot containers appears only while dragging or with the "Drop zones" highlight on. No changes outside the Builder.
API changes
None. RealRenderTrait::buildComponentSlot() changes visibility from private to protected so the Builder panel can override it; the default (wrapped) implementation is unchanged for other panels.
Data model changes
None. Stored instance data is untouched; the markers exist only in the Builder's rendered output.
Testing steps
- Build an entity view / page layout containing components with grid and flex slots (e.g.
dro-hero, feature grids). - Open the Builder: the rendering matches the front-end output — inspect the iframe DOM to confirm slot content are direct children of the slot container, with
<!--db-slot:…-->markers around them. - Drag components between slots and reorder within a slot; verify insertion position survives the round trip.
- Enable "Drop zones": each slot shows its outline and label at its own top-left corner; empty slots give the pointer somewhere to land.
- Click a component that renders as an empty link: the Builder must not navigate or scroll.
Issue fork display_builder-3634089
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
pdureau commentedThat's very interesting.
I guess it is what Canvas does. And what Christian wanted to do last year.
Comment #4
prince.wangReady to review
Comment #5
christian.wiedemann commentedThat's great! I will do a review. I did some ugly CSS workaround to taggle the issue.
Comment #6
christian.wiedemann commentedHi again,
i checked the code and it works well. Right now adding cells to a grid is only possible with CSS hacks inside the theme. I think the MR is a big step.
What I would change is the htmx prepartion from JS to server side..
Than we can make the change much smaller and leave the js mostly untouched.
Comment #8
pdureau commentedSo the ticket is "Needs work".
@christian.wiedemann @ipumpkin don't forget to assign the ticket to you when it is you turn and assign it when you finish your turn. So we know when someone is working on it or no.
Comment #9
prince.wangComment #10
prince.wang@christian.wiedemann @pdureau i have created a new branch 3634089-remove-dropzone-wrapper-v2 for christian's note, but frontend change almost same , pls review and judge which one is better
Comment #11
mogtofu33 commentedComment #12
mogtofu33 commented!532 is the right direction and I would close !530.
Rebased on 1.0.x, it loses #3628832: Show slot constraints in the Builder and the Scaffold in the Builder: the slot payload skips addDropzoneConstraints(), so the count, the lock and the label are gone, and DropzoneTest::testConstraintsOnSlots and tree.spec.ts "Navigator slot constraints" fail.
A component whose root element prints its slot, such as test_2 or Bootstrap's card_group, gets data-slot-id on its own root and stops being a node, so it cannot be selected or dragged in the Builder.
In a multi-slot holder the first slot's markers are removed before the second one is found, so a drop into the first slot posts to the second.
I also limited the click preventDefault() to links again: it was blocking every click in the iframe.
Comment #13
prince.wangComment #15
prince.wangfixes for the two test issues
Issue A: A component root that prints its slot gets marked as a slot, so the node cannot be selected or dragged
Root cause: annotateSlotMarkers() stamped data-slot-id and other slot attributes directly on the marker's parent element. When that parent is the node's own root (test_2, Bootstrap card_group, test_button), the NODE selector [data-node-id]:not([data-slot-id]) excludes it.
Fix: a node root is no longer turned into a slot.
The node root keeps its own data-node-id / data-node-title and other node attributes.
It gets data-db-node-slots, and its markers stay; the client partitions members by those markers.
The first slot's identity now lives in non-conflicting attributes: data-db-slot-id, data-db-slot-title, and data-hx-post, used by the contextual menu, library click-insert, and the count label.
See BuilderPanel.php:112 and BuilderPanel.php:328.
Issue B: In a multi-slot holder, the first slot's markers were removed before the second slot was found, so a drop into the first slot posted to the second
Root cause: the old implementation processed markers while walking them and deleted the first slot's start/end comments immediately. By the time it detected a second slot in the same element, the first slot's markers were already gone, so the client could only see the second slot.
Fix: group markers by holder and count them before processing any of them.
Multiple slots in one element: keep every marker, and keep the first slot's attributes for its label and the contextual menu.
A single slot whose holder is not a node root: stamp the holder attributes and remove the markers as before.
The client reads only the holder's direct-child markers (so nested markers cannot interfere), maps the global member position to “which slot + position inside that slot”, and posts to that slot's URL.
See builder_frame.js:44, builder_frame.js:130, and builder_frame.js:641.
Related integration updates
Tests
Comment #16
mogtofu33 commentedThanks, the three points are fixed: the constraints are back in the Builder, test_2 stays a node you can select and drag, and node_slots.spec.ts covers it.
The shared holders now send four operations to the wrong slot, and the stored tree confirms each one.
A drop into an empty first slot lands in the second one.
A library click after a node of slot_2_2 inserts into slot_2_1. Ctrl+D on test_2 nests the copy inside test_2. Ctrl+D on a child of slot_2_2 nests the copy inside that child.
The last three work on 1.0.x. annotateSlotMarkers() also breaks three PHPMD thresholds, now caught by the pipeline since the rebase.