MishkaCmsCore.Runtime.PageBuilder.Placement (Mishka CMS Core v0.0.1)

Copy Markdown View Source

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

after_node(tree, node)

The {parent_id, index} immediately after node among its own siblings.

head(tree)

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.

hints(tree, dragged, opts \\ [])

@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.

index_after(tree, target)

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.

note(tree, node_id, parent_id)

@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.

relocation(tree, head, node_id, parent_id, opts \\ [])

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.

spoken(node, opts)

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.

subtree(template)

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.

target(tree, head, node_id, opts \\ [])

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.