The editor
The editor is a view over rows. A chapter of the course is a row, each of its sections is a row, and a section holds its blocks in one field. Nothing is a text blob. This page settles the words, what lives in the store, how two copies of a section merge, and how the browser is kept out of the model. The Operations page holds the contract, one card per edit. The section “Where it stands”, near the end, says which of this exists and which is still a plan.
Three packages share the work, and the dependency runs one way.
@epure/editor is the core: the types, the pure model, the notation,
markdown in and out, and one small storage port of plain data. It depends on
nothing, and it is the third instrument of the method beside @epure/vitest,
which runs the scenarios, and @epure/minidoc, which publishes them: this
one writes them. @tilia/editor renders: React over contentEditable, input
events into acts, the block views keyed by id through tilia. @lapa/editor
fills the port with rows and adds what only lapa can give: drafts, proposals,
sharing, a section in two documents.
Vocabulary ■
A document is a row that orders sections. A chapter of the course is a document. A book is a document too, with chapters under it in place of sections, because a row may hang under a row of the same class.
A section is a row, and it is the unit of everything social. It is what a person shares, drafts, proposes or transcludes. It is also the smallest thing lapa syncs, merges and reaches on its own. A section keeps its blocks in one field and its atoms in another. The definition of a topology with its three axioms is a section. The proof of a theorem is a section. A section has no heading level and no nesting. Its heading, when it has one, is a block inside it.
A block is one element of the first field: an id and a content. It is the
smallest thing the editor renders, and the thing the Operations page draws one
line for. A block has a form. A paragraph holds prose. A heading holds
a title at one of three levels. An item is one line of a list. A
display holds one reference alone and draws it as a block: a formula on
its own line, a video, a quiz. Paragraph is the everyday word for the common
block. The model says block. The form is the line’s prefix on the port, #
or - , and is not text in the model: offsets count from the first letter.
The content of a block is runs: plain text and a list of marks. A mark is a kind, such as bold, italic, code or link, over a span of the plain text, counted in characters. The caret and the selection are offsets in the same plain text, so they map onto the rendered text one to one.
An atom is one element of the second field: a type and a text, and the one piece drawn from them that the browser may not edit. A formula is an atom of type math, and its text is the LaTeX source. A video is an atom of type video, and its text names the row and its placement. The atoms of a section are a dictionary keyed by id, and the editor mints those ids as it mints a block’s. A type is what the host knows about an atom: how to draw it, and how to merge two copies of it. The core knows no type.
A reference is how a block holds an atom: {{a8f1}}, the id between
double braces, as characters of the plain text under a mark of its own. A
reference inside a sentence is inline. A reference alone in a display block
is a block. The atom is the same in both, drawn by its type for its place,
and its text never sits in the block. The caret sits before or after an atom,
never inside. The text of an atom is edited in a box the editor opens under
it.
Expansion is for later. A type may answer a reference with more text to read, so that a template names other atoms, and a letter or a résumé is written from one. The word is settled here, and nothing on this page uses it yet.
The field that holds the blocks is a keyed array: an ordered list, each element keyed by its id. The order is the array. An id never moves from one content to another.
DocumentChapter 2, topological spaces
SectionDefinition of a topologyunderposition a0
- aDefinition
- bA topology on a set X is a collection τ of subsets of X, called open sets, such that:
- c
$$\emptyset \in \tau \quad\text{and}\quad X \in \tau$$ - dAny union of open sets is open. Any finite intersection of open sets is open.
- e
VideoThe three axioms, drawnunder
SectionFirst examplesunderposition a1
- aThe discrete topology holds every subset of X.
- bThe indiscrete topology holds only ∅ and X.
Rows and reach ■
Two things run through the tree above, and they are not the same thing. Reach
is the graph: an edge from the chapter to each section, and from a section to
the video it embeds. Structure is a relation: each section names its chapter
in its parents field, with a position beside the name. The graph says who
may see what. The relation says what comes before what. Neither does the
other’s job.
Order lives on the child, not on the parent. A section’s entry for a parent
holds a string position, a fractional key that always has room for one more
between two neighbours. The children of a chapter are one indexed hop back
along that relation, sorted by position. Inserting a section writes one row,
the section, and the edge that hangs it. The chapter row is not touched. This
is what lets a contributor with append add a section, and what lets a
proposed section place itself. It also means a reader who cannot reach a
section never receives its id, since the entry that orders it travels with the
section.
Sharing is one edge into the section. Whoever the edge reaches sees the
section and everything under it, so the video and the quiz come along without
another write. Transclusion is a second entry in parents: the same section,
in two chapters, at a position in each. The entries merge per parent, so a
move in one chapter and a share into another both survive.
DocumentChapter 2, topological spaces
SectionExercise: is the cofinite topology Hausdorff?underposition a3same row
- aLet X be infinite and let τ hold ∅ and every set with finite complement.
- b
QuizPick two distinct points and try to separate themunder
DocumentChapter 5, separation axioms
SectionExercise: is the cofinite topology Hausdorff?underposition a0same row
Inside a section ■
The keyed array is the section’s whole content. A text block holds runs. An embed holds a small dictionary: the id of the row it draws, the row’s class, and the parameters of its placement, such as a width or a caption. The row holds the content, the video’s file or the quiz’s questions, in fields of its own class with their own validation. Nothing is stored twice. The dictionary says where and how the row appears. The row says what it is.
Ids are minted by the editor and never change. Enter splits a block, and the first half keeps the id while the second half takes a new one. Backspace at the start of a block joins it with the previous one, and the first id survives. A move inside the section is a change of place in the array, with the id untouched. These three rules are what makes the id something a later merge can rely on.
An embed and its row have separate lifetimes. Deleting the embed leaves the row under the section, so that undo can bring the block back and find its row waiting. A row under a section that no embed names is dangling, and a sweep or an explicit remove clears it. The other way round, a section whose embed names a row that has not arrived yet draws a placeholder from the dictionary’s class, and fills it when the row lands.
SectionCompactness
- aA space is compact when every open cover has a finite subcover.
- b
- cCompactness is preserved by continuous images.
VideoCovering the circleunderwidth widecaption The circle, covered by arcs
Merge ■
Lapa merges a record three ways against the base it keeps. A plain field takes the newer stamp, and two edits to one field conflict whole. This is the only way built today. A text field will run diff3, so two edits to different places in one paragraph both land, and two edits to the same place surface as a conflict with markers a person or an AI can read. The keyed array is the third way. The last two are the merge work this editor asks of lapa.
A keyed array merges entry by entry first, and as texts second. Against the base, every entry outside the longest run still in base order has moved, and each side’s moves are played after the entry before them. Then every id whose text changed on both sides runs diff3 over its words, with git markers where both sides changed one stretch. The atoms of a section are a second keyed array and merge the same way. So a conflict lands on one block or one atom, not on the section, and the view can show it in place. How a block draws the markers it received is still open.
The shapes to name are few. Edited here and deleted there is a conflict. Moved here and moved there is a conflict. Inserted at the same gap by both is an interleave: the merge takes a fixed order, and a person fixes the order in seconds. Two concurrent splits of one block at the same point produce two new ids with the same text, one after the other. It is rare, it is visible, and it is the price of an id that never drifts.
- runsthe block the editor holds while it has focus
- keyed arraythe section's one content field
- recordone row, synced whole and merged against its base
- bindingone tracked entry per block, keyed by id
- viewonly the block whose entry changed re-renders
A remote edit landing in the block that has focus is the delicate case. The local runs stay the source while the block is focused. The merged text arrives, a diff of the old text against the new gives an offset map, and the caret moves through it. The typist never sees the caret jump.
The port ■
The editor and its host meet at one boundary, and a section crosses it as a list of blocks, each block its id and its text in canonical markdown. The host stores strings and never sees a mark. The editor reads a block’s markdown into runs when it renders it, and writes runs back to markdown when it hands the block out. Runs never leave the editor.
Sectioncompactness
- aA space is compact when every open cover has a finite subcover.
- b**Compactness** is preserved by continuous images.
- c
The port itself is two things, both plain data. The sections the host has
given the editor to edit, by id, and one call, update, that the editor
makes with the sections an act changed, whole, once per act. The core
observes nothing. Making a change re-render is the rendering binding’s job:
it holds the sections in a tilia tree, re-reads a section the host wrote,
and re-renders the blocks whose entries changed. An in-memory store for
tests is the same port with update writing back into the sections.
Debouncing keystrokes is the host’s choice, not the editor’s.
- actenter, a typed letter, bold over a selection
- modela pure edit on runs and offsets
- sectionthe changed blocks written back as markdown
- updatethe host receives the section, whole
Embeds are opaque to the editor. An embed block is a fenced dictionary with a type and parameters, rendered through a component the host injects for that type. The editor never asks what the id means. The lapa binding supplies the components that resolve it to a row.
Files ■
Markdown is the file and interchange format, not the storage format. Pack
writes a section as plain markdown: a paragraph per block, a heading with its
hashes, a formula between double dollars, and an embed as a fenced block named
lapa that carries the row’s id, its class and its placement. Block ids stay
out of the file, so the file a human or an AI edits stays clean. Unpack
matches the edited text back to ids by diff against the previous pack. A fence
with no id names a row that does not exist yet, so unpack mints the row from
the fence’s class and parameters and writes the id back on the next pack.
Pack is canonical. The serializer has one form for each set of marks and one order for the keys of a fence, so a hand-edited file that used another style produces no merge noise once it has been through unpack and pack once.
The input layer ■
The browser is an input device, not the model. A section renders into one contentEditable root, one paragraph per block, keyed by the block’s id. A paragraph holds exactly the plain text of its block, wrapped in the tags its marks call for and nothing else: no marker characters. That keeps the offset map one to one. An atom is the one exception, and it keeps the map by a rule: the element that draws an atom counts as the characters of its reference, whatever it shows, and the caret sits before or after it. Chrome holds no caret beside an atom at the edge of a block unless a text node stands there, so a zero-width space is written on each side of an atom, and the bindings count it as nothing. The browser edits text inside a paragraph and is allowed nothing beyond that; every change of structure is the model’s.
Every input announces itself as a beforeinput event with a type, and the
type decides who edits. A structural type is prevented and applied to the
model as an act: a paragraph break is enter, a backspace at the start of a
block or over a selection across blocks is backspace, a delete at the end
is delete, a paste is paste, and a mark toggle is bold or italic. A
text-level type reaches the DOM, and the model reads the block back on the
input event that follows: a typed letter, a replacement from autocorrect,
a dead key, a deletion inside a block, a deleted word. The Operations page
calls that act input: the block as the browser left it, whole, with its
caret. The model diffs the old text against the new one to find the span
that changed, so marks move with it, and typing, composition, dead keys and
autocorrect all land on the same edit. IME composition only waits: the
browser composes, and the block is read back on compositionend.
The block the browser touched is then rebuilt from the model rather than patched. React reconciles against what it rendered last, not against what the browser did, so a patch after a native edit doubles a character at a mark boundary. A block whose content changed takes a new key, its DOM is built again from runs, and the selection is restored from model offsets.
One more rule keeps the two in step. A key can arrive before the
selectionchange that follows the previous one, so every handler first
brings the model’s selection level with the DOM’s, and only then decides
whether a backspace is a join or a deletion.
Every edit is a pure function on the model: input, delete backward and forward, split, join, toggle a mark, set a link, move a block, paste, place the caret, step it. No function touches the DOM, and every one of them is tested with no browser before it is tested with one.
Marks ■
A caret holds pending marks: the marks the next typed character takes. It takes them from its place, whether an arrow or a click put it there: inside a bold, italic or code run at its end, outside a link at its end, outside any run at its start. So a mark extends when the caret types at its end, unless it is a link, and never extends at its start. Punctuation is the exception: a period or a comma typed at the end of a run lands outside it, since it closes the phrase the run was. A space at the end of a code span does the same, since a span is one identifier. After typing, the pending marks are those the last typed character took; after a deletion they follow the character before the caret.
The keys are Cmd+B, Cmd+I and Cmd+E. On a selection they toggle the mark over it, skipping code spans; on a caret they change the pending marks, and so take the other side wherever the caret sits. There are no markdown input rules, since a typed marker is a character on some keyboards and a dead key on others. An empty run never exists in the model, which is why the DOM never needs a zero-width space to hold one.
Marks do not apply inside a code span. Toggling a mark over a range that contains a code span skips the span. Overlapping marks are fine in the model. Markdown cannot say them, so the serializer splits at every boundary, emits each segment with its full set of marks, and merges adjacent segments whose sets are equal.
Bold and italic never open or close on a space, in the model as in the file,
because CommonMark refuses **bold **. Two runs of one of them parted by
spaces alone are one run. So typing a space at the end of a bold run leaves
the space plain and keeps bold pending, and the next letter rejoins the run
across it. A code span and a link keep their edges, since a space inside them
is content.
Across blocks ■
One selectionchange listener on the document is enough. Each paragraph
carries its block’s id, and the listener resolves the anchor and focus nodes
of the selection to their blocks and offsets, then places the model’s
selection there. Because the section is one editable root, a selection
across blocks is the browser’s own and needs no drawing.
A delete over a selection that spans blocks trims the first block, trims the last, deletes the blocks between and joins the two ends. A letter typed over such a selection does the same and then lands in the joined block. Arrow left and right are the model’s, one character at a time, and cross to the next or the previous block at the edges. Arrow up and down are the browser’s, since they depend on line layout, and the model only places the caret where they put it.
Block-selection mode, where a selection that leaves a single block turns into a selection of whole blocks, is planned and not built.
Copy and paste ■
Paste reads text/plain as markdown, one block per line, and parses it into
blocks and runs. A paste that yields one block inserts text and marks at the
caret. A paste that yields several blocks splits the current block: the first
pasted block joins the text before the caret, the last joins the text after,
both the way a join does, and the rest sit between as new blocks. Reading
text/html first, and copy, which will write the selection as markdown in
text/plain and as rendered marks in text/html, are still to come.
A paste inside the same section is a change to one keyed array. A paste into another document must clone the rows its embeds name under the new section, because a reference alone may point at a row the reader of the new document cannot reach. Pasting a book is thousands of blocks, hundreds of sections and a few rows for its videos in one batch, and the batch must be comfortable.
Undo ■
Undo is not built yet. It will be an editor stack of model edits, one per session, because browser undo breaks across re-renders and across rows. Each step will store the inverse edit and the selection before it, so undo restores both the text and the caret. Today the browser’s undo is prevented and does nothing.
Testing ■
The model is pure and is tested with no browser. Seventy cards on the
Operations page are the edits, and ten more scenarios in the Storage group
are the port: a corpus of markdown that round trips to runs and back without
change, with escapes, code spans, delimiters inside them, links with brackets
in their text, nested marks, empty lines and the two canonicalizations, and
two acts whose update is checked whole. Property tests, which would apply
random sequences of edits and check that every mark stays inside its text,
that offsets stay ordered, and that a split followed by a join gives back the
original, are still to write. Cross-block operations run against a plain list
of blocks before any row exists.
The browser runs the same fixtures. The binding’s suite loads each
scenario’s document into the dev page through a hook, turns each act into
keys, and reads the document back in the notation. An input act becomes
the span that changed, selected and typed over. What no key can drive, a
paste or a link, is skipped and says so. So a rule holds in the model and in
Chrome by the same card, and the two cannot drift apart. Safari and Firefox
are still to come.
The hard parts stay hard. Escapes and code spans in the serializer and parser: a literal asterisk, a bracket inside link text, a mark that must stop at a code span. Canonical form against diff3 noise. IME composition and Safari selection quirks.
Roads not taken ■
One row per paragraph was the first design. It gives every paragraph an id in the store, and that is all it gives. Each paragraph would be a node with stamps, a position and a target for share edges. A pull would descend one node per paragraph, a share of an exercise would need one edge per member, and every Enter would be a graph write. The keyed array keeps the id and drops the node.
A keyed array on the chapter, ordering its sections, would work once the
sequence merge exists. It loses anyway. Adding a section would need edit
on the chapter instead of append, a reader would receive the ids of
sections they cannot reach, every structural change by anyone would rewrite
one hot row, and a removed section would leave its id behind. Positions on
the child have none of these costs.
A character-level CRDT inside a block buys live co-typing in one paragraph and nothing else. Its state is opaque, every edit must go through the library, and a hand-edited file breaks it. Diff3 on a block is enough.
Float positions run out after about fifty inserts in one gap, and a rebalance rewrites every sibling, which is a stop-the-world batch in a local-first design. String keys extend instead.
Ids in the packed text would make the file a human edits unclean. Identity lives in the row. YAML in a text field would make structured data prose. An embed’s placement is a dictionary that merges as fields, and the row’s content is fields of its class.
Open ■
A conflicted block. The merge writes git markers inside one text, which is one line on the port, and the editor has no rule yet for drawing a block that holds them.
Bullet points. Where a list is one paragraph block and where each item is its own block. The previous answer was to split into blocks past ten items.
The evaluator trigger on a relation change, sibling of the trigger on an
under edge, is a cost to schedule on the lapa side.
Where it stands ■
@epure/editor exists and is the model described here: the types, runs,
the inline markdown reader and writer, the notation, every edit on the
Operations page, and the port with its two crossings. A hundred scenarios
pass with no browser. Blocks have three forms, prose, heading and item; a
formula or an embed still reads as literal text on the port.
@tilia/editor exists as an experiment in this workspace, to move to tilia’s
own once its shape has settled. It renders one section, headings and lists
included, splits input by type between the browser and the model, rebuilds
the block the browser touched, keeps the model’s selection level with the
DOM’s, and hands the changed section to the port after every act. The Demo
page of this site is that editor over the page itself, with what the port
received under it. Eighty-two of the cards run in Chrome through the same
fixtures; six are skipped because no key drives them. Composition is wired
and untested, and Safari and Firefox are untouched.
@lapa/editor does not exist. Nothing on the rows and reach pages is built.
@lapa/db merges a record field by field; diff3 on a text field and the
keyed array’s sequence merge are still to write there.
| Stage | State |
|---|---|
| Inline model, edits, round-trip corpus | done; property tests to write |
| Block list: split, join, move, delete across, paste | done |
| One section in React: input, rebuild, selection | done in Chrome |
| Across blocks: selection, delete, typed text | done; block-selection mode planned |
| Copy and paste | paste from plain text; copy and HTML to come |
| Undo | not started |
| Block forms: headings and items | done |
| Formulas and embeds | not started |
| Lapa: text diff3, the sequence merge, rows through the port | not started |
Order of work ■
The order follows the difficulty. Row work is easy: positions, the parents
relation with its metadata, diff3 on a text field, the sorted hop back. Edits
inside a block are medium and need very good functional testing. Cross-block
work is medium: one selection watcher, fixed attributes per block, one
callback that updates only what changed. Copy and paste is medium hard,
because it is many block updates and row inserts in one batch. Inline edits
with marks are hard, because editing inside a text block while honouring
links, bold, italic and the rest is not trivial.
- inline modeledits, the round-trip corpus and the property tests
- block listsplit, join, move, delete across, the paste result
- one section in Reactbeforeinput, composition, selection restore
- across blockscross-block selection and block-selection mode
- copy and pasteboth clipboard formats, the paste batch
- undothe stack of inverse edits
- block formsheadings and items, then formulas and embeds through an injected component
- lapathe keyed array, its sequence merge, rows bound through the port