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

Copy Markdown View Source

One open editing session: the working tree plus everything around it — selection, hover, undo history, clipboard, per-node locks, the breakpoint and state being edited.

Why the server holds this

The editor is two LiveViews — the chrome and the canvas inside its iframe — and they must agree on one document. They share a token, not a copy: every mutation goes through apply_op/3 here, lands in ETS, and is announced on the session's PubSub topic, so each LiveView re-renders what it owns. That single funnel is also where containment is re-checked, where the undo history is pushed, and where the builder:node:* plugin seams fire.

It is also what makes undo, clipboard and shortcuts real. The React editor shipped a complete history implementation watching a store nothing ever wrote to, so every one of its thirteen advertised shortcuts was inert. Here the history is the same data structure the canvas renders from, so a working undo is not a feature to add later — it falls out.

What lives here, and what does not

This module is the funnel and the state: it reads the session, applies a change, checks containment, writes it back and announces it. Two things it used to also be were lifted out, because each is a whole question of its own and neither needs the token or the table to answer it:

  • MishkaCmsCore.Runtime.PageBuilder.Placement — WHERE a thing lands. A click, a drag and a template arriving on its own all ask it the same question, and it answers from the tree alone. It sits beside Containment, which answers whether a child may go in a parent at all;
  • MishkaCmsCore.Runtime.PageBuilder.EditorSession.Journal — WHAT HAPPENED, in the words a person would use, and the undo stack it sits on. It owns coalescing, trimming and the step rows; the documents themselves stay in EditorHistoryState and the durable rows in History.

Both take and return plain data, so they can be read and tested without opening a session — which is what they were missing.

Two more answer for the page's forms, and both are consulted from the funnel rather than owned by it: MishkaCmsCore.Runtime.PageBuilder.PageForms.FormPlacement puts a form around a field that lands without one, and MishkaCmsCore.Runtime.PageBuilder.PageForms.Naming renumbers a form name that a copy, a paste or a pasted document has just made ambiguous.

History semantics (from the v2 prototype)

As deep as its memory budget allows rather than a fixed number of steps — see MishkaCmsCore.Runtime.States.EditorHistoryState, which holds each step as what changed rather than as a whole document, and drops the oldest when a session has kept too much. A new edit truncates the redo stack. Consecutive text edits on the same node inside 600 ms coalesce into one entry, so typing a headline is one undo, not forty.

Concurrency

Two admins on the same page see each other through Phoenix.Presence on this topic, and edits are last-write-wins at the op level. Per-node locking is the escape hatch. A real CRDT is explicitly out of scope; op-level granularity keeps the damage from a genuine collision to a single element.

One session is written by more than one process as well — the chrome, the canvas, and the tasks each of them starts to flush the history, autosave and load the palette. None of those writes may put back what another one changed, so every change here is worked out on the session as stored and landed with a compare-and-swap; see MishkaCmsCore.Runtime.States.EditorSessionState.update/2.

One change, or many

apply_op/3 is one change: one read of the session, one write, one broadcast, one undo step. That is the right shape for a person, and the wrong shape for anything that produces a document — an assistant writing a section, an import, a paste of forty elements. Each op there costs a full copy of the session out of ETS and back, a history snapshot, and a re-render in every LiveView watching, so forty ops is forty of everything and the editor spends the whole time drawing intermediate states nobody asked to see.

Anything writing more than one thing should call apply_ops/4. It folds the run over one tree in memory, stores it once, broadcasts once, and keeps one undo entry — which is also what a reader means by "undo what it just did". Measured on a 302-node page inserting forty elements: 15.7 ms and forty history entries, against 3.6 ms and one.

Summary

Functions

Tells everyone editing this session that something changed, without changing the session.

Applies a drop, and when the position asked for is refused, puts the node beside the target rather than dropping the gesture on the floor.

The one mutation funnel: validate the op, re-check containment, apply it, record history, store and announce.

Applies a RUN of ops as one change: one read, one write, one broadcast, one undo step.

The breakpoints an override may be recorded against.

The same session WITHOUT registering it.

One action over everything selected, as a single undo step.

Everything selected, in document order.

Back to one element.

Closes a session and drops its state.

One right-click menu action, whichever surface asked for it.

Copies a subtree to the clipboard — the prototype's "Holding …" status.

Copies how a node LOOKS, without what it says.

Copies a subtree and removes it.

Take the settled entries waiting to be written, and clear them from the session.

Whether the thing being dragged is a field on a Page — a template whose controls are one field, or a node that is part of one — so the canvas says which form it joins while it is dragged.

Which nodes could take this drag as a child — the map the canvas paints its drop line from.

Switches what the editor is editing to a draft: its content becomes the working tree, and every save from here — including autosave — goes to that draft.

The document a history entry would restore.

A sample %MishkaCmsCore.Runtime.PageBuilder.EditorSession{} populated from defaults + type-based fallbacks.

Fetches an open session.

Every place a phrase appears in the document's words.

A fresh session token — 32 lowercase base32 characters, also the canvas iframe's URL segment.

Whether a node is folded away on the canvas — Node.hidden?/2 explains what the set holds.

How many steps back and forward are available — the {n}/{m} counter in the top bar.

Marks the hovered node — drives the canvas outline and the layers highlight.

Whether a node is locked.

Adds a node to the selection, or takes it out again — a ⌘-click.

Marks the session saved — clears dirty? and stamps saved_at.

Moves every field of the form from_id into the form into_id and removes from_id, as one undo step, then selects into_id. Answers the sentence to show. See FormPlacement.merge_ops/3.

Moves the field node_id belongs to into the form target (a node id, or FormPlacement.new_target/0 for a new form with a Send button), rebinding it there, as one undo step. A form it leaves with no fields is removed. Answers the sentence to show. See FormPlacement.move_ops/3.

Opens a session over a template and stores it.

Pastes the clipboard after the current selection, or into parent when one is given. Refuses an empty clipboard rather than doing nothing silently.

Puts a copied look onto another node, replacing whatever it had.

Arms (or disarms) click-to-place with a palette component's name.

Whether an overlay is being held open on the canvas.

Places the armed component into parent_id at index, then disarms.

Places the armed thing where a click on node_id means.

Places a template that arrives without being armed first — a saved snippet, an example lifted out of the library, a component the wizard composed.

The element states an override may be recorded against.

Replaces the palette metadata containment consults — re-read when a component is installed.

Replaces the working tree wholesale — what a save, a draft restore or a history revert does.

Records the wizard's choices for a component, so its preview canvas can render them.

Steps one entry forward.

Replaces a phrase everywhere it appears, as one undo step.

Put entries back after a failed write, without disturbing anything queued since.

The resource sections the builder can edit.

Selects a node, or clears the selection with nil.

Switches the breakpoint being edited. Overrides written afterwards land in that bucket.

Switches the writing direction the CANVAS is rendered in.

Hands the canvas the compiled per-element CSS, so the frame paints it immediately.

Turns the canvas's editor overlays off and on.

Switches the element state being edited (:base, :hover, :focus, :active).

Records which way the author means to save — draft or published.

Holds one element's CSS for the publish to write, a blank body holding the clearing of it.

Subscribes the calling process to a session's updates.

Flips a node's eye: draws one that was folded away, folds one that was drawn.

Locks or unlocks a node. A locked node refuses selection-driven edits and moves.

Holds an overlay open on the canvas, or lets it shut again.

Hides a node from visitors, or shows it again — see MishkaCmsCore.Runtime.PageBuilder.Visibility.

The PubSub topic both LiveViews of a session subscribe to.

Steps one entry back. Returns the session unchanged when there is nothing to undo, so a bound keystroke never errors.

Drops the hold, which only the publish that has written it may do.

Replaces the working tree with one worked out from the tree as stored — what a condition, a hook, a click action or an element's scope class does, none of which is an op.

The bucket a style edit is written to — the width AND the state the panel is showing.

Types

t()

@type t() :: %MishkaCmsCore.Runtime.PageBuilder.EditorSession{
  actor_id: String.t() | nil,
  breakpoint: atom() | nil,
  clipboard: any() | nil,
  clipboard_label: String.t() | nil,
  components: list() | nil,
  dir: String.t() | nil,
  dirty?: boolean() | nil,
  draft_id: String.t() | nil,
  draft_label: String.t() | nil,
  element_css: String.t() | nil,
  future: list() | nil,
  hidden: any() | nil,
  hover: String.t() | nil,
  last_entry: any() | nil,
  locked: any() | nil,
  marked: any() | nil,
  opened_at: any() | nil,
  past: list() | nil,
  pending: any() | nil,
  picked: String.t() | nil,
  pinned: any() | nil,
  preview?: boolean() | nil,
  pseudo: atom() | nil,
  resource_id: String.t() | nil,
  revision: integer() | nil,
  saved_at: any() | nil,
  section: String.t(),
  selection: String.t() | nil,
  site_id: String.t() | nil,
  site_name: String.t() | nil,
  stage: atom() | nil,
  staged_element_css: any() | nil,
  status: MishkaCmsCore.Runtime.ErrorStatus.t() | nil,
  step_run: any() | nil,
  styles: any() | nil,
  styles_label: String.t() | nil,
  token: String.t(),
  tree: any() | nil,
  wizard: any() | nil
}

Functions

announce(token, message)

@spec announce(String.t(), term()) :: :ok

Tells everyone editing this session that something changed, without changing the session.

For state that lives OUTSIDE the session and is shared by everyone in it — review notes, which are rows in MishkaCmsCore.Runtime.PageComment rather than a field here. Both editors already listen on this topic for every op, so a note posted by one appearing for the other is the existing collaboration channel doing what it is for, rather than a second one beside it.

apply_drop(token, params)

@spec apply_drop(String.t(), map()) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}

Applies a drop, and when the position asked for is refused, puts the node beside the target rather than dropping the gesture on the floor.

A drag paints its line from drop_hints/2, so reaching a refusal here means the hint was stale or the payload was made up. Either way the person dragging has already said where they want this, and "immediately after the thing you aimed at" is the answer they would pick themselves — a <span> that cannot hold a section still tells us which section the new one belongs next to.

Returns {:ok, session}, {:relocated, session, sentence} where the sentence says what happened and is meant to be shown, or {:error, reason} when there is no room beside the target either. It retries once: a second refusal is a real one.

apply_op(token, params, opts \\ [])

@spec apply_op(String.t(), map(), keyword()) :: {:ok, t()} | {:error, term()}

The one mutation funnel: validate the op, re-check containment, apply it, record history, store and announce.

An op that leaves the tree exactly as it was is applied but never recorded — see MishkaCmsCore.Runtime.PageBuilder.EditorSession.Journal.record/4.

params is raw client input. Returns {:ok, session} or {:error, reason}, where reason is either a validation error list or a user-facing containment message.

announce: how announces builder:form:after_create with that how for each form the op made work.

apply_ops(token, params_list, label, opts \\ [])

@spec apply_ops(String.t(), [map()], String.t(), keyword()) ::
  {:ok, t()} | {:error, term()}

Applies a RUN of ops as one change: one read, one write, one broadcast, one undo step.

apply_op/3 is the right shape for a person, who makes one change at a time and wants each of them undoable on its own. It is the wrong shape for anything producing a document — an assistant writing a section, an import, a paste of forty elements — because every op there costs a full copy of the session out of ETS and back, a history snapshot, a broadcast, and a re-render in every LiveView watching. Forty of those is forty of everything, and the editor spends the whole time redrawing intermediate states nobody asked to see.

Here the ops are folded over one tree in memory and stored once. The undo step is the whole run, which is also what a reader means by "undo what it just did".

Stops at the first op that will not apply and returns its error, leaving the session untouched — a half-written section is worse than a refused one. label names the single history entry.

opts may also say whose run this was, which is what the record needs to tell an assistant's work from a person's: author_type: :ai, plus the note it wrote about what it did, the prompt it was given and the backend that answered. An :ai run is also allowed a larger restore point — see MishkaCmsCore.Runtime.PageBuilderSnapshot. announce: how announces builder:form:after_create with that how for each form the run made work.

breakpoints()

@spec breakpoints() :: [atom()]

The breakpoints an override may be recorded against.

build(attrs)

@spec build(map()) :: {:ok, t()} | {:error, term()}

The same session WITHOUT registering it.

A LiveView mounts twice — once for the static render, once when the socket connects — and registering on both left an orphaned session in ETS on every page load, with a different token, so the canvas iframe pointed at one URL and then at another and loaded twice. The static render uses this; only the connected mount calls open/1.

builder(attrs_or_input, error \\ false)

bulk(token, action)

@spec bulk(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

One action over everything selected, as a single undo step.

DESCENDANTS ARE DROPPED FIRST. Holding a card and something inside it and pressing Delete would otherwise delete the card, and then fail on a child that went with it — halting the run and leaving the rest of the selection untouched.

chosen(map)

@spec chosen(t()) :: [String.t()]

Everything selected, in document order.

Order matters for every caller: wrapping three cards has to keep them in the order they appear on the page rather than the order they were clicked, and deleting has to start from a stable list.

clear_marks(token)

@spec clear_marks(String.t()) :: {:ok, t()} | {:error, term()}

Back to one element.

close(token)

@spec close(String.t()) :: :ok

Closes a session and drops its state.

context_action(token, action, node_id)

@spec context_action(String.t(), String.t(), String.t()) ::
  {:ok, t()} | {:open, :snippet} | {:error, term()}

One right-click menu action, whichever surface asked for it.

The canvas and the layers rail are separate LiveViews, and a menu implemented twice is a menu that drifts — the same entry deleting a branch in one place and one node in the other. Both hand the action here instead.

{:open, :snippet} is the one entry that is not an edit: saving a selection for reuse is a form, and the form already exists in the Studio. Each surface opens it its own way, which is the only part they legitimately differ on.

copy(token, node_id)

@spec copy(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Copies a subtree to the clipboard — the prototype's "Holding …" status.

copy_styles(token, node_id)

@spec copy_styles(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Copies how a node LOOKS, without what it says.

A second clipboard on purpose. Styling four cards to match by copying the first one and retyping its words is not the same gesture as duplicating it, and one clipboard cannot hold both — copying a subtree to paste its look would have thrown away whatever was waiting to be pasted.

What travels is the class attribute, expression and all, and the recorded per-breakpoint and per-state overrides. Nothing else: not the text, not the children, not the id — and not the minted pb- class, which names the element it was copied from rather than anything about its look.

cut(token, node_id)

@spec cut(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Copies a subtree and removes it.

drain_pending(token)

@spec drain_pending(String.t()) :: {:ok, [map()]} | {:error, term()}

Take the settled entries waiting to be written, and clear them from the session.

Called by the flush, off the LiveView process. Returning them and clearing in one write is what stops two overlapping flushes writing the same row twice — and if the write then fails, the ids are stable, so re-queuing them costs nothing.

drop_field?(token, params)

@spec drop_field?(String.t(), map()) :: boolean()

Whether the thing being dragged is a field on a Page — a template whose controls are one field, or a node that is part of one — so the canvas says which form it joins while it is dragged.

drop_hints(token, params)

@spec drop_hints(String.t(), map()) ::
  {:ok, %{required(String.t()) => boolean()}} | {:error, term()}

Which nodes could take this drag as a child — the map the canvas paints its drop line from.

Answered once per drag, at dragstart, rather than once per hover: the geometry of where the pointer is inside a box is the browser's to compute, but whether a drop is legal is this server's, and asking it per pixel of pointer travel is what a round trip per hover would mean. One call, one map, and the line is instant for the rest of the gesture.

A locked node answers false — authorize/2 would refuse the drop, and a hint that disagrees with the refusal is worse than no hint. Dragging a locked node answers false everywhere, for the same reason.

The map is a hint, and can go stale: a collaborator can move the tree under a drag in flight. apply_drop/2 is what actually decides.

edit_draft(token, map, template)

@spec edit_draft(String.t(), map(), String.t()) :: {:ok, t()} | {:error, term()}

Switches what the editor is editing to a draft: its content becomes the working tree, and every save from here — including autosave — goes to that draft.

The history is deliberately kept. Opening a draft is a move within one editing session, not a new one, so undo still walks back past it; what it must NOT do is silently continue writing to whatever was open before, which is why draft_id and draft_label move together.

enforce_keys()

enforce_keys(field)

entry_tree(session, arg2)

@spec entry_tree(t(), map()) ::
  {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t()} | :error

The document a history entry would restore.

The entry itself carries only a reference — see MishkaCmsCore.Runtime.States.EditorHistoryState — so anything offering "go back to this", like the history rail's Revert, asks here. :error when the tree is no longer held, which a caller should treat as "that step is gone" rather than "the document is empty".

example()

A sample %MishkaCmsCore.Runtime.PageBuilder.EditorSession{} populated from defaults + type-based fallbacks.

fetch(token)

@spec fetch(String.t()) :: {:ok, t()} | :error

Fetches an open session.

find_text(arg1, needle)

@spec find_text(t(), String.t()) :: [
  %{id: String.t(), text: String.t(), count: pos_integer()}
]

Every place a phrase appears in the document's words.

TEXT ONLY, and that is the whole point: a page says "Contact Us" in four places and the fifth is a class called contact-us. Renaming the company should not touch the CSS, so attributes, class names, expressions and comments are not searched — what comes back is what a reader would call the page's words.

Each hit carries the node it is in and enough of the line to be recognised, in document order.

generate_token()

@spec generate_token() :: String.t()

A fresh session token — 32 lowercase base32 characters, also the canvas iframe's URL segment.

hidden?(map, node)

Whether a node is folded away on the canvas — Node.hidden?/2 explains what the set holds.

history_counter(session)

@spec history_counter(t()) :: {non_neg_integer(), non_neg_integer()}

How many steps back and forward are available — the {n}/{m} counter in the top bar.

hover(token, node_id)

@spec hover(String.t(), String.t() | nil) :: {:ok, t()} | {:error, term()}

Marks the hovered node — drives the canvas outline and the layers highlight.

keys()

keys(field)

locked?(map, node_id)

@spec locked?(t(), String.t()) :: boolean()

Whether a node is locked.

mark(token, node_id)

@spec mark(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Adds a node to the selection, or takes it out again — a ⌘-click.

The FIRST one becomes the ordinary selection rather than a mark, so holding ⌘ from the start behaves the way clicking does. Selecting something new plainly means "and now this instead", so select/3 clears the marks; only this and clear_marks/1 write them.

mark_saved(token, opts \\ [])

@spec mark_saved(String.t(), keyword()) :: {:ok, t()} | {:error, term()}

Marks the session saved — clears dirty? and stamps saved_at.

:as_of is the revision the caller serialized before it wrote. A session that has moved past it holds work that write did not carry, so it keeps dirty? and only takes the draft the write made — the editor stays clean about what is really saved instead of reporting the author's newer change as kept. Asked here rather than by the caller because the answer has to be read in the same commit that stores it. Omit it when nothing can come between the serialize and this call.

merge_forms(token, from_id, into_id)

@spec merge_forms(String.t(), String.t(), String.t()) ::
  {:relocated, t(), String.t()} | {:error, term()}

Moves every field of the form from_id into the form into_id and removes from_id, as one undo step, then selects into_id. Answers the sentence to show. See FormPlacement.merge_ops/3.

move_to_form(token, node_id, target)

@spec move_to_form(String.t(), String.t(), String.t()) ::
  {:relocated, t(), String.t()} | {:error, term()}

Moves the field node_id belongs to into the form target (a node id, or FormPlacement.new_target/0 for a new form with a Send button), rebinding it there, as one undo step. A form it leaves with no fields is removed. Answers the sentence to show. See FormPlacement.move_ops/3.

open(attrs)

@spec open(map()) :: {:ok, t()} | {:error, term()}

Opens a session over a template and stores it.

:template is the HEEx to edit; a malformed one returns {:error, %{line:, column:, message:}} so the editor can show it rather than failing the mount.

paste(token, opts \\ [])

@spec paste(String.t(), keyword()) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}

Pastes the clipboard after the current selection, or into parent when one is given. Refuses an empty clipboard rather than doing nothing silently.

A pasted field joins the form it lands in, or starts one, as a dropped field does; that answers {:relocated, session, sentence}.

paste_styles(token, node_id)

@spec paste_styles(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Puts a copied look onto another node, replacing whatever it had.

ONE UNDO STEP. Replacing a look is a clear of every bucket followed by a write of each override, which is a dozen ops for one gesture — through apply_ops/4 they collapse into a single entry, so undo takes the card back to how it looked rather than unwinding it one property at a time.

pick(token, picked)

@spec pick(String.t(), String.t() | nil) :: {:ok, t()} | {:error, term()}

Arms (or disarms) click-to-place with a palette component's name.

Session state rather than chrome state for the same reason set_preview/2 is: the click that places the component happens inside the CANVAS, which is a different LiveView. Left in the chrome, the palette card would light up and clicking the page would select an element instead of placing anything — which is exactly what it did before this moved.

pinned?(arg1, node_id)

@spec pinned?(t(), String.t()) :: boolean()

Whether an overlay is being held open on the canvas.

place(token, parent_id, index)

@spec place(String.t(), String.t(), non_neg_integer()) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}

Places the armed component into parent_id at index, then disarms.

Lives here rather than in either LiveView because both of them need it: the chrome places from the structure tree, the canvas from a click on the page itself, and they must not be able to disagree about what "the picked component" means or where it ends up.

place_at(token, node_id)

@spec place_at(String.t(), String.t() | nil) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}

Places the armed thing where a click on node_id means.

A LEAF — a kit component, a heading, an image — goes inside that element when it will take one, and immediately after it when it will not. The fallback is the whole point. Clicking a <.component> that declares 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 — and answering it with "that element cannot hold children" puts the burden of the containment rules on them. Webflow, Elementor and Bricks all land here too.

A BAND does not follow that rule. A band is a horizontal slice of the page and belongs beside the other slices, so a click pointing into one climbs out and lands after that whole branch. Without this, a call-to-action clicked while the page header was selected became a third item in the header's flex row and rendered a quarter of the width. Bricks documents Section as root-level and will not offer it inside a layout element; Elementor's containers refuse section outright.

Returns {:relocated, session, note} when the landing place is neither the clicked element nor beside it, because that is the case the selection ring does not explain on its own. Passing nil places at the end of the document.

place_template(token, template, node_id)

@spec place_template(String.t(), String.t(), String.t() | nil) ::
  {:ok, t()} | {:relocated, t(), String.t()} | {:error, term()}

Places a template that arrives without being armed first — a saved snippet, an example lifted out of the library, a component the wizard composed.

Same rule as place_at/2, and that is the point: three routes that all mean "put this where I am looking" used to disagree, because this one always dropped its template beside the selection. A <section> snippet inserted while a heading deep inside a hero was selected landed inside that hero — the bug place_at/2 exists to prevent, reached by another door.

EVERY ELEMENT CLASS IN IT IS MINTED AGAIN, alongside every other placement. All three were written in ANOTHER document — the page a snippet was cut from, the component an example documents — so the pb-xxxxxxxx classes they carry are keys into that document's CSS row, which this one neither owns nor prunes (MishkaCmsCore.Runtime.PageBuilder.ElementScopes). The rules do NOT travel: a snippet stores markup and nothing else, and what the element looked like in the other document was that document's rule showing through the one site stylesheet. Fresh classes are what make the element styleable from here. Copy, paste and Duplicate are the ones that keep theirs: they stay inside one document, where sharing the class is the point.

DRAGGING THE SAME CARD IS THE OTHER DOOR, and it does not come through here: a drag carries its markup in the payload and lands in apply_drop/2, which mints there for this reason.

pseudos()

@spec pseudos() :: [atom()]

The element states an override may be recorded against.

put_components(token, components)

@spec put_components(String.t(), list()) :: {:ok, t()} | {:error, term()}

Replaces the palette metadata containment consults — re-read when a component is installed.

put_tree(token, tree, opts \\ [])

@spec put_tree(String.t(), MishkaCmsCore.Runtime.PageBuilder.Tree.t(), keyword()) ::
  {:ok, t()} | {:error, term()}

Replaces the working tree wholesale — what a save, a draft restore or a history revert does.

Whatever arrives is renumbered first. A document typed into the code drawer, imported, or restored from a draft written before this rule existed can hold two forms answering to one name, and the page that comes out of it routes both forms' submissions to one handler.

put_wizard(token, choices)

@spec put_wizard(String.t(), map()) :: {:ok, t()} | {:error, term()}

Records the wizard's choices for a component, so its preview canvas can render them.

Announced like everything else: the preview is a second LiveView in an iframe, and the session is the only thing the two share.

redo(token)

@spec redo(String.t()) :: {:ok, t()} | {:error, term()}

Steps one entry forward.

replace_text(token, needle, replacement)

@spec replace_text(String.t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, term()}

Replaces a phrase everywhere it appears, as one undo step.

A replace-all is one gesture and has to undo like one — forty separate entries would mean forty presses to get back to where you were, and no way to tell which of them was the accident.

requeue(token, entries)

@spec requeue(String.t(), [map()]) :: :ok

Put entries back after a failed write, without disturbing anything queued since.

sections()

@spec sections() :: [String.t()]

The resource sections the builder can edit.

select(token, node_id, origin \\ :panel)

@spec select(String.t(), String.t() | nil, :canvas | :panel) ::
  {:ok, t()} | {:error, term()}

Selects a node, or clears the selection with nil.

origin rides along in the broadcast because the two LiveViews answer a selection differently: clicking an element ON THE CANVAS is a request to work on it, so the chrome swings the left panel to the Inspector, while clicking a row in the layers tree is navigation and must leave the panel where the reader put it. Both end at the same selection; only the chrome's reaction differs.

A plain click means "this instead", so anything held with ⌘ is let go — see mark/2.

set_breakpoint(token, breakpoint)

@spec set_breakpoint(String.t(), atom()) :: {:ok, t()} | {:error, term()}

Switches the breakpoint being edited. Overrides written afterwards land in that bucket.

set_dir(token, dir)

@spec set_dir(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Switches the writing direction the CANVAS is rendered in.

Session state rather than chrome state, because the thing that has to flip is the document being edited — an author checking a Persian or Arabic page wants the page mirrored, not the editor around it. Riding on the session is what gets it into the iframe without reloading the canvas.

set_element_css(token, css)

@spec set_element_css(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Hands the canvas the compiled per-element CSS, so the frame paints it immediately.

The rules themselves live in a stylesheet row — see MishkaCmsCore.Runtime.PageBuilder.ElementCss — and reach the published page through the compiler. This is the editor's copy, and nothing reads it but the canvas.

set_preview(token, preview?)

@spec set_preview(String.t(), boolean()) :: {:ok, t()} | {:error, term()}

Turns the canvas's editor overlays off and on.

Session state for the same reason set_dir/2 is: the thing that has to change is what the canvas draws — selection outlines, the floating toolbar, the expression and comment chips, the drop tail. Left in the chrome, the button would light up and the page would look exactly the same.

set_pseudo(token, pseudo)

@spec set_pseudo(String.t(), atom()) :: {:ok, t()} | {:error, term()}

Switches the element state being edited (:base, :hover, :focus, :active).

set_stage(token, stage)

@spec set_stage(String.t(), :draft | :published) :: {:ok, t()} | {:error, term()}

Records which way the author means to save — draft or published.

Kept on the session because it decides whether a reload picks the work back up: a draft is written as it is made and resuming it loses nothing, while publish-mode work was never written anywhere.

stage_element_css(token, scope, body)

@spec stage_element_css(String.t(), String.t(), String.t()) ::
  {:ok, t()} | {:error, term()}

Holds one element's CSS for the publish to write, a blank body holding the clearing of it.

Read out of the session rather than merged in by the caller, because two browsers share one session: a caller that put its own map back would drop whatever the other one applied between.

subscribe(token)

@spec subscribe(String.t()) :: :ok | {:error, term()}

Subscribes the calling process to a session's updates.

toggle_hidden(token, node_id)

@spec toggle_hidden(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Flips a node's eye: draws one that was folded away, folds one that was drawn.

Hidden is session state, not document state: it is the layers panel's eye toggle, a way to get a fixed header out of the way while working underneath it. It is deliberately not serialized, so saving never silently drops an element an author only meant to stop looking at.

The set holds the ids whose eye differs from the default for their kind — see MishkaCmsCore.Runtime.PageBuilder.Tree.Node.hidden?/2. Membership therefore reads as "folded" for an element and "drawn" for a comment, which is why nothing outside that predicate should reach into the set directly.

toggle_lock(token, node_id)

@spec toggle_lock(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Locks or unlocks a node. A locked node refuses selection-driven edits and moves.

toggle_pinned(token, node_id)

@spec toggle_pinned(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Holds an overlay open on the canvas, or lets it shut again.

An overlay opens on its own while it is selected — see the data-pb-overlay rules in MishkaCmsCoreWeb.Live.Admin.Builder.CanvasLive — so pin one only to keep it open while working on something else, which is the case selection alone cannot express.

Session state like hidden, and never serialized: a dialog left pinned is still a dialog that opens from its trigger on the published page.

toggle_visibility(token, node_id)

@spec toggle_visibility(String.t(), String.t()) :: {:ok, t()} | {:error, term()}

Hides a node from visitors, or shows it again — see MishkaCmsCore.Runtime.PageBuilder.Visibility.

Not the eye above: this is document state, one attribute in the markup, so it goes through apply_op/3 like any other edit — undo, the history, the lock and the plugin hooks come with it.

topic(token)

@spec topic(String.t()) :: String.t()

The PubSub topic both LiveViews of a session subscribe to.

undo(token)

@spec undo(String.t()) :: {:ok, t()} | {:error, term()}

Steps one entry back. Returns the session unchanged when there is nothing to undo, so a bound keystroke never errors.

unstage_element_css(token)

@spec unstage_element_css(String.t()) :: {:ok, t()} | {:error, term()}

Drops the hold, which only the publish that has written it may do.

update_tree(token, change, opts \\ [])

@spec update_tree(
  String.t(),
  (MishkaCmsCore.Runtime.PageBuilder.Tree.t() ->
     {:ok, MishkaCmsCore.Runtime.PageBuilder.Tree.t()} | term()),
  keyword()
) :: {:ok, t()} | {:error, term()} | term()

Replaces the working tree with one worked out from the tree as stored — what a condition, a hook, a click action or an element's scope class does, none of which is an op.

change is handed the stored tree and answers {:ok, tree} to store tree the way put_tree/3 does, with the same options. Anything else is answered as it is and nothing is stored. It is asked again whenever another write lands first, so it must do nothing it cannot repeat.

write_bucket(map)

@spec write_bucket(t()) :: atom()

The bucket a style edit is written to — the width AND the state the panel is showing.

It used to be one or the other: the breakpoint while Normal was selected, the state otherwise. So a hover colour set with the panel on Mobile was written to :hover, which is every width, and changed the desktop with it. There was no way at all to say "on a phone, when touched".

Styles.bucket/2 composes them. A state at the widest breakpoint keeps its own name, because hover: with no media query already applies everywhere — so the cheap case stays cheap: set a hover on Desktop and it holds on a phone unless the phone says otherwise.