A document says what is there. A selection says where you are in it.

Quillon addresses documents two ways that used to have nothing between them. Quillon.Path addresses nodes — [0, 2] is the third child of the first child — and never characters. Quillon.Transform addresses characters — offset 5 — but only inside one block. A selection is the pair: which block, and where inside it.

Points

A Quillon.Selection.Point is one place: a path to a block and an offset counted inside it.

Quillon.point([0, 2], 5)
#=> %Quillon.Selection.Point{path: [0, 2], offset: 5}

Treat a point as opaque. Build it with Quillon.Selection.point/2, read it with Quillon.Selection.Point.path/1 and offset/1, and do not match on the struct. Anchors are expected to grow an id-based form once documents carry stable ids, and code that goes through the accessors will keep working when they do.

The offset counts what Quillon.Transform.Position measures, which is not always what a reader counts. A node holding blocks, and any node declaring atomic: true, occupies exactly one position no matter how much text is inside it. That is what makes a footnote marker selectable rather than zero-width. See Document Model for the interior-flow rules.

Selections

A selection is two points. The anchor is where it started; the head is where the caret is now.

# A plain cursor - anchor and head together
Quillon.cursor([0], 3)

# A range
Quillon.selection(Quillon.point([0], 1), Quillon.point([0], 4))

# One whole node, as a unit
Quillon.Selection.node([2])

Head may come before anchor when the selection was dragged backwards. Nothing assumes an order, so call Quillon.Selection.normalize/1 before treating a selection as a range:

backwards = Quillon.selection(Quillon.point([0], 7), Quillon.point([0], 2))
{from, to} = Quillon.Selection.normalize(backwards)
Quillon.Selection.Point.offset(from)
#=> 2

Two kinds

A :text selection covers characters. A :node selection covers one node whole, which is what atomic: true exists for — a range of characters cannot express "this chip is selected".

Quillon.Selection.node([2]) |> Quillon.Selection.node?()
#=> true

Resolving

Quillon.Selection.resolve/2 turns a selection into the block it points at plus offsets in the form the transform layer takes:

doc = Quillon.document([Quillon.paragraph("Hello")])
{:ok, resolved} = Quillon.resolve(doc, Quillon.cursor([0], 2))
resolved.kind
#=> :text

It fails with :invalid_path when the path is not there, :out_of_range when an offset falls outside its block, and :cross_block for a text selection whose ends are in different blocks.

A cross-block selection is representable — the two points simply have different paths — but the editing functions do not act on one yet.

Quillon.Selection.clamp/2 pulls overshooting offsets back inside their block, which is what a stored caret needs after the block it was in got shorter. It will not guess at a path that no longer exists.

Editing through a selection

Quillon.Edit is the document-scoped layer over Quillon.Commands. Every function takes a document and a selection, and answers with the document and the new selection, so the caret follows what just happened:

doc = Quillon.document([Quillon.paragraph("HelloWorld")])

{:ok, doc, selection} = Quillon.split_block(doc, Quillon.cursor([0], 5))
Quillon.to_html(doc)
#=> ~s(<div class="quillon"><p>Hello</p><p>World</p></div>)

Quillon.Selection.Point.path(selection.head)
#=> [1]
FunctionWhat it does
apply_mark/3, remove_mark/3, toggle_mark/3Format the selected run
has_mark?/3Whether the whole run carries a mark
insert_block/3Add a block after the caret's block, caret moves into it
split_block/2Break a block at the caret, caret moves to the second half
delete_selection/2Remove what the selection covers

insert_block/3 does not split the block the caret is in — inserting mid-paragraph puts the new block after that whole paragraph. Call split_block/2 first when you want the paragraph broken.

Errors are :invalid_path, :out_of_range, :cross_block, and :unsupported_block for a block the transform layer does not edit, which today means anything other than a paragraph or heading.

What this is not

These functions return a selection for their own edit. They do not rebase a selection somebody else is holding. General position mapping — every transform reporting how offsets moved so any stored caret survives any edit — is not built. That decision belongs with the collaboration layer, which is what determines how positions are tracked across concurrent edits.

Several people's cursors

A selection is a plain value that serializes, so presence is a map of them:

%{
  "alice" => Quillon.cursor([0], 5),
  "bob" => Quillon.selection(Quillon.point([1], 0), Quillon.point([1], 4))
}
Quillon.Selection.to_json(Quillon.cursor([0], 3))
#=> %{"kind" => "text",
#     "anchor" => %{"path" => [0], "offset" => 3},
#     "head" => %{"path" => [0], "offset" => 3}}

Carets live beside the document, never inside it. Writing them into the AST would put ephemeral view state into undo history and into whatever syncs the document.

Quillon.to_html/2 draws them on request:

Quillon.to_html(doc,
  cursors: [
    %{id: "u1", label: "alice", color: "#f00", selection: Quillon.cursor([0], 5)}
  ]
)
#=> ~s(<div class="quillon"><p>Hello<span data-cursor data-cursor-id="u1" ...></span> world</p></div>)

A collapsed selection becomes an empty <span data-cursor>. An expanded one also wraps what it covers in <span data-cursor-range>, as does a node selection around its node. Position them with CSS — the renderer emits hooks and takes no view on how a caret looks. Because cursors are passed per render, the same document draws differently for each viewer.