Where a thing lands — the answer shared by a click, a drag, and a template that arrives on its own.
This sits beside MishkaCmsCore.Runtime.PageBuilder.Containment, which answers a different
question: containment says whether a child MAY go in a parent, and placement decides which parent
and which index a gesture actually meant. Every route into the document goes through one function
here, which is what stops three doors disagreeing about the same drop.
The two rules
A leaf — a kit component, a heading, an image — goes inside the element that was clicked when
it will take one, and immediately after it when it will not. The fallback is the point rather than
a consolation: clicking a <.component> with no slot, an <img> or an <input> is not a mistake,
it is someone pointing at the place they want the new thing to appear. Webflow, Elementor and
Bricks all land there too.
A band — a <section>, a <main>, anything dropped from the Blocks library — is a horizontal
slice of the page and belongs beside the other slices. A click pointing into one climbs out of the
whole branch. Without that, a call-to-action clicked while the page header was selected became a
third item in the header's flex row and rendered at a quarter width. Bricks documents Section as
root-level; Elementor's containers refuse section outright.
What counts as a place to put a band
@band_hosts is the two obvious tags plus the two cases that matter on a real page: anything that
already holds a band, and anything still empty. A page wrapped in <div id="app"> keeps its bands
in that div, and a rule that knew only the tag names would lift every new section out of the
wrapper. An empty box is a hole to put things in — the one container that cannot squeeze a band
into a narrow third column, because there is nothing beside it. The canvas already draws exactly
these hollow.
Hints are not decisions
hints/3 answers every node at once so the canvas can paint the whole tree at dragstart, and it
asks Containment directly. It deliberately does NOT go through EditorSession's own gate, which
fires the builder:containment plugin event — that runs on the drop, once, where a plugin author's
code belongs; running it here would put it on the drag's critical path several hundred times.
A hint can also go stale: a collaborator can move the tree under a drag in flight. The drop decides.
Summary
Functions
The {parent_id, index} immediately after node among its own siblings.
The first root of template, which is what the containment rules get asked about and what decides
whether this is a band.
Which nodes will accept dragged, keyed by node id — every node in the tree, so the canvas can
colour all of them from one answer.
The index immediately after target among its siblings, or nil when it has no place there.
A sentence for a landing place the selection ring does not explain on its own, or nil when it
needs no words.
The sentence for where a click placed head, or nil when the landing explains itself: a band's
note/3, or for a leaf that landed further out than beside the clicked node, why the clicked
node's parent refused it.
A node in a sentence about where something was placed: a working form by its people name, a form
that does not work yet as "the form", anything else as :other (default "the element around
it"). :components is the palette.
The template's own tree.
The {parent_id, index} a click on node_id means for head, the first root of the template
being placed.
Functions
@spec after_node( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() ) :: {String.t() | :root, non_neg_integer()}
The {parent_id, index} immediately after node among its own siblings.
@spec head(String.t() | MishkaCmsCore.Runtime.PageBuilder.Tree.t() | nil) :: MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil
The first root of template, which is what the containment rules get asked about and what decides
whether this is a band.
nil when the template will not parse — the insert is about to fail on the same parse, so the
failure stays in one place rather than being guessed at twice.
@spec hints(MishkaCmsCore.Runtime.PageBuilder.Tree.t(), term(), keyword()) :: %{ required(String.t()) => boolean() }
Which nodes will accept dragged, keyed by node id — every node in the tree, so the canvas can
colour all of them from one answer.
opts takes :components (the palette, for resolving a component's own containment),
:dragged_tree (the tree dragged belongs to, so a Block's own form is visible to the
form-nesting rule) and :locked (a MapSet of ids the author has pinned). A locked node refuses
even when the rules would allow it, and dragging something locked refuses everywhere at once:
nowhere will take it, and saying so up front is what stops the canvas painting a line it would
then have to withdraw.
@spec index_after( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() ) :: non_neg_integer() | nil
The index immediately after target among its siblings, or nil when it has no place there.
Separate from after_node/2 because the caller that retries a refused drop needs the index on its
own, to merge into a payload that already names the parent.
@spec note( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), String.t() | nil, String.t() | :root ) :: String.t() | nil
A sentence for a landing place the selection ring does not explain on its own, or nil when it
needs no words.
Landing beside the element that was clicked is visible: the insert makes the new node the selection and the canvas scrolls to it. Landing somewhere else entirely, because the click was on a box buried inside a band, is not — so that one says where it went, in the words the drag path already uses.
@spec relocation( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil, String.t() | nil, String.t() | :root, keyword() ) :: String.t() | nil
The sentence for where a click placed head, or nil when the landing explains itself: a band's
note/3, or for a leaf that landed further out than beside the clicked node, why the clicked
node's parent refused it.
opts are target/4's.
@spec spoken(MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t(), keyword()) :: String.t()
A node in a sentence about where something was placed: a working form by its people name, a form
that does not work yet as "the form", anything else as :other (default "the element around
it"). :components is the palette.
@spec subtree(String.t()) :: MishkaCmsCore.Runtime.PageBuilder.Tree.t() | nil
The template's own tree.
A caller that only needs the head can hand this straight to head/1, and one asking target/4
where a Block may land needs the whole thing: a block opens with a band tag by design, so its
<.form> is a CHILD of the head and the form-nesting rule cannot see it from the head alone.
nil when the template will not parse.
@spec target( MishkaCmsCore.Runtime.PageBuilder.Tree.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.Node.t() | nil, String.t() | nil, keyword() ) :: {String.t() | :root, non_neg_integer()}
The {parent_id, index} a click on node_id means for head, the first root of the template
being placed.
nil for node_id — or an id that is no longer in the tree — means the end of the document, which
is what a click on empty canvas is. opts takes :components, :dragged_tree (the template's own
tree, so a Block's own form is visible to the form-nesting rule) and :band?, the last for a
Block, which is a band whatever its markup happens to open with.