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)
#=> 2Two 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?()
#=> trueResolving
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
#=> :textIt 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]| Function | What it does |
|---|---|
apply_mark/3, remove_mark/3, toggle_mark/3 | Format the selected run |
has_mark?/3 | Whether the whole run carries a mark |
insert_block/3 | Add a block after the caret's block, caret moves into it |
split_block/2 | Break a block at the caret, caret moves to the second half |
delete_selection/2 | Remove 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.