mirror of
https://github.com/priyanshujain/margin-docs.git
synced 2026-10-02 11:07:05 +00:00
508 lines
40 KiB
Markdown
508 lines
40 KiB
Markdown
# Architecture
|
|
|
|
Tauri 2, React 19, Vite, TypeScript and zustand on the front, Rust behind. Same stack as margin and
|
|
margin-calendar, which means the build and bundle setup, the token layer and the TipTap editor core
|
|
carry over from margin rather than being invented again, while the zustand and CSS conventions
|
|
carry over from margin-calendar, the newer and more disciplined of the two scaffolds.
|
|
|
|
The split is strict. Rust owns the filesystem: reading and writing documents, walking and watching
|
|
the tree, the SQLite index, moving files to the trash, and atomic writes. TypeScript owns
|
|
everything above that: the markdown bridge that turns file bytes into an editable document and
|
|
back, the WYSIWYG editor itself, and all rendering. Rust never parses markdown and TypeScript never
|
|
touches the filesystem directly; everything between the two crosses through the `dto.rs`/`ipc.ts`
|
|
contract described below.
|
|
|
|
## The markdown bridge
|
|
|
|
The bridge is built on `remark-parse` and `remark-stringify` through `unified`, not on
|
|
`@tiptap/markdown`, TipTap's own markdown extension. Two things rule that out. `@tiptap/markdown` is
|
|
built on `marked`, which has no frontmatter support, and frontmatter here is not optional: it has to
|
|
be parsed, hidden from the editor, and written back unchanged on every save. More fundamentally, a
|
|
`marked` AST does not carry source positions, while every node `remark-parse` produces carries a
|
|
`position` with byte offsets into the original file. That offset is the whole trick the rest of this
|
|
document depends on: when a piece of syntax has no model in the editor, the bridge does not
|
|
reconstruct its text from a generic AST node, it slices the exact bytes out of the original source
|
|
between `position.start.offset` and `position.end.offset` and carries that slice forward untouched.
|
|
Writing back byte identical is a property of the source string, not of the printer, and only an AST
|
|
that remembers where it came from can hand that string back.
|
|
|
|
## Where a hand wrapped line lives
|
|
|
|
Most markdown in the world is hard wrapped: a paragraph is several lines in the file and one
|
|
paragraph on screen, and the author chose where those lines end. That choice is theirs and this
|
|
editor does not get to reflow it, so a soft line break survives the round trip as a real newline in
|
|
the paragraph's text rather than being folded into a space.
|
|
|
|
Keeping it takes three declarations that have to agree, and it is worth naming all three because
|
|
each one on its own looks like an optimisation somebody could remove. `src/markdown/parse.ts` puts
|
|
the newline in the text. `prose.css` draws a paragraph `pre-wrap` so it is on screen where the
|
|
author put it. `src/model/schema.ts` declares the paragraph `whitespace: "pre"`, and that is the one
|
|
that was missing: two libraries read that field and both of them rewrite the paragraph without it.
|
|
`prosemirror-view` reads it to decide how to parse the editor's own DOM back after a keystroke, and
|
|
at the default every soft wrap in the paragraph being typed into came back as a hard break, so one
|
|
character typed into a hand wrapped file put a backslash at the end of every line in that paragraph.
|
|
`prosemirror-transform` asks the same field before it joins two blocks, so Backspace between two
|
|
hand wrapped paragraphs rewrote the wraps in both.
|
|
|
|
The heading needs all three for the same reason and was declared with none of them, which is the
|
|
same bug wearing the one hat markdown lets an author wrap by hand. A setext heading is two lines of
|
|
text over an underline, and mdast writes a break inside one as a backslash, so a character typed
|
|
into a two line heading put a backslash in the user's words exactly as it used to in a paragraph.
|
|
The first save cannot rescue it either, because a heading that has been wrapped has no ATX spelling
|
|
to be normalised into, so it stays setext and every later keystroke re-arms the bug. The field goes
|
|
on at every level rather than only the two that have an underline: the deeper four cannot be wrapped
|
|
in the file, but they can hold a line ending written `
`, and at the default a keystroke turned
|
|
that into a break the writer then swallowed as a space.
|
|
|
|
The paragraph's parse rule says the opposite on purpose, and a rule's own answer outranks the
|
|
node's. Whitespace is significant in a paragraph this editor rendered, and meaningless in a `<p>`
|
|
off somebody else's web page, where the line endings are that page's source indentation and keeping
|
|
them would put breaks and runs of spaces through the middle of a pasted sentence.
|
|
|
|
Only a running editor can prove any of this: the fault is between the keystroke and the serializer,
|
|
and the serializer never saw it. `tests/bytes.spec.ts` is where that proof lives.
|
|
|
|
## Callouts, toggles and unknown syntax
|
|
|
|
On disk a callout is a GitHub alert: a blockquote whose first line reads `[!NOTE]`, `[!WARNING]`,
|
|
`[!IMPORTANT]`, `[!TIP]` or `[!CAUTION]`. `remark-parse` hands back an ordinary `blockquote` node for
|
|
these, so a visitor walks the tree afterward, recognises the marker in the first child, tags the
|
|
node with its variant and strips the marker before the editor ever sees it. Serialization reverses
|
|
exactly that: re-emit the blockquote and put the marker back as its first line.
|
|
|
|
A toggle is `<details>` and `<summary>`, and remark does not parse HTML blocks into a tree at all;
|
|
it hands them back as opaque `html` nodes carrying raw text. The bridge looks for a matching
|
|
`details`/`summary` pair in that raw text, and where it finds one it lifts the summary and the body
|
|
into a toggle node the editor can open and close like a native control. A `details` block that does
|
|
not match that exact shape, nested oddly, missing a summary, carrying attributes the editor has no
|
|
model for, is left exactly as it was found. Everything else the parser cannot classify, an HTML
|
|
block that is not a details/summary pair, a construct remark itself does not model, a piece of
|
|
syntax nobody has written a visitor for yet, becomes a raw node under the rule in
|
|
[conventions.md](conventions.md): the literal source slice, editable as monospace text, written
|
|
back unchanged. Nothing is ever silently reformatted or dropped.
|
|
|
|
## Tables, maths and the rest of what the bridge learned to model
|
|
|
|
The first pass over the bridge modelled prose and left everything structural as a raw node. A GFM
|
|
table, a `$$` math block and a `<details>` toggle were all bytes the editor showed and refused to
|
|
touch. Modelling them is what turns those blocks from something the editor carries into something
|
|
it edits, and every one of them was built as the exact inverse of the writer that puts it back: the
|
|
first mdast row becomes header cells and the delimiter row's per column alignment is copied on to
|
|
every cell of that column, because markdown has no way to say that one cell is centred and the rest
|
|
of its column is not. That inverse relationship is the whole test: a construct is modelled only
|
|
where parsing it and writing it back is the identity, and the parser refuses everything else.
|
|
|
|
The refusals are the interesting half and they are all the same shape. A table row with more cells
|
|
than its header has no column to put them in, so there is no answer between dropping the cells and
|
|
inventing a column, and the block stays raw. A toggle whose summary does not survive being unescaped
|
|
and escaped again character for character stays raw. A math fence that runs to the end of the file
|
|
unclosed stays raw, because writing it back would invent the closing delimiter. So does a carriage
|
|
return anywhere inside any of the three, since remark reads a lone `\r` as a line ending and the
|
|
writer never puts one back. In every case the file keeps its exact bytes and the user gets an
|
|
editable monospace block instead of a rich one, which is the trade this project has always made.
|
|
|
|
Modelling a table does cost one thing, and it is on the record rather than hidden. The house style
|
|
writes a table with its cells padded out to the width of the column, and a table written compact by
|
|
hand is repadded by the save that settles the file. Nothing else about it moves, no cell content
|
|
changes, and the second save is byte identical, which is the same one time normalisation the house
|
|
style has always applied to a setext heading or a tilde fence. The alternative would be a `source`
|
|
attribute on the table node holding the author's spelling, and that cannot work: the schema is
|
|
frozen, and the editor rebuilds every node from JSON when it installs a document, so the node the
|
|
writer sees is never the node the parser made. `roundtrip.test.ts` names the two corpus files this
|
|
affects and asserts, cell by cell, that the padding is the only thing that changed.
|
|
|
|
## What the writer proves before it writes
|
|
|
|
mdast writes the tree, so escaping, fence lengths and list indentation are decided by the library
|
|
that read the file rather than by string concatenation. What mdast cannot decide is whether this
|
|
reader will hand the block back as the block the document holds, and several spellings turn on
|
|
exactly that. A url the file wrote bare should stay bare, not become `[https://x](https://x)` and
|
|
put a diff in front of somebody who edited a different paragraph. A code span or an equation the
|
|
file wrapped across two lines should keep its line ending rather than have it swapped for a space.
|
|
A `<summary>` should keep the line endings the file gave it. None of those is safe on the strength
|
|
of a regular expression here, because GFM's literal autolink grammar has more corners than one, and
|
|
every corner that was got wrong cost a destination.
|
|
|
|
So the writer proves rather than assumes, and it proves the same way every time: write the block
|
|
out, read it back with the pair the app opens files with, and compare. There are two ladders. The
|
|
outer one is about the block, and it has three rungs, from writing every line ending where the
|
|
document puts it, through handing the spans back to mdast, to writing the two a text node cannot
|
|
hold as `
` and `
`. The inner one is about a url and also has three, shortest first: the
|
|
url on its own, `<url>`, then `[url](url)`, which has no grammar left to get wrong. The last rung of
|
|
the outer ladder is written whether it verifies or not, because a document holding something
|
|
markdown cannot spell still has to be saved and the one thing that must not happen is a file that
|
|
gains bytes on every save for the rest of its life.
|
|
|
|
The comparison is against the ProseMirror node, not against the mdast tree the block was built
|
|
from, and that is the whole point of it. The two trees disagree in ways that mean nothing: remark
|
|
reads an item's `spread` off the blank lines inside it while the writer sets it from the list, and a
|
|
parser extension hangs its own fields on the nodes it made. Every one of those differences is a
|
|
comparison that fails on a block that is perfectly fine, and each one was a rung of this ladder
|
|
quietly switched off. The document is the thing that has to survive, so the document is what is
|
|
compared.
|
|
|
|
The comparison itself is by name, not by object. The block the writer built and the block it reads
|
|
back are bound to two different `Schema` instances over one set of specs, so every `NodeType` and
|
|
every `MarkType` in one is a different object from its twin in the other, and `Node.eq`, which
|
|
compares them by identity, answered "different" for every block it was ever given. That was the
|
|
whole ladder switched off at the top rung, and `src/document.ts` has the same comparison for the
|
|
same reason.
|
|
|
|
Two questions about a file are not questions about any block in it, and each has a check of its
|
|
own because the ladder cannot see either. The seam between two blocks is one: a blank line closes
|
|
every construct markdown has except a list, which carries on over one and swallows the block below,
|
|
so a list written next to preserved source is proved apart and respelled, with a wider item indent
|
|
or the other bullet, until it is.
|
|
|
|
A rule inside a list item is the same question asked from the other side, and it is the one place
|
|
the house style has to give way rather than the seam. `---` is the house spelling and it is a setext
|
|
underline wherever a paragraph is still open above it, which inside a tight item is the only place
|
|
the line under the item's text can be. Reading that as "these two blocks cannot be written a line
|
|
apart" is correct about `---` and wrong about the rule, so the writer spread the whole list to make
|
|
room, and a tight list came back loose with its items wrapped in paragraphs they did not have. `***`
|
|
is the same rule in the one spelling that does interrupt a paragraph, the writer already reaches for
|
|
it when a rule lands on the first byte of a file, and a tight item now gets it. A loose item keeps
|
|
`---`, because the blank line in front of it has already closed the paragraph.
|
|
|
|
Those respellings cover every raw block a file can hand over, because a file's own raw block starts
|
|
in the first three columns and column four is indented code. An edited one can be anything, and for
|
|
some of them no spelling exists at all: four spaces typed into a raw block below a list makes bytes
|
|
that no seam can hold, since they read back as a code block wherever they are put. So the seam has a
|
|
last resort. When a list beside a raw block has no spelling left, the raw block is written from the
|
|
bytes the file gave it and the edit does not reach disk that save. That is the one place this editor
|
|
knowingly drops something the user typed, and it is the right way round: the alternative measured on
|
|
the same file swallowed the list into the raw block and then moved bytes around inside somebody's
|
|
html on the save after. A raw block with no list beside it is written exactly as it always was.
|
|
|
|
Dropping it is the decision; dropping it in silence was the bug. The writer had no way to say it had
|
|
happened, so the save path marked the buffer clean over bytes the edit was not in: the file stopped
|
|
changing, the unsaved dot went out, and the only trace was the edit still sitting on screen. The
|
|
writer now reports the refusal on the way past, the dirty flag has that as a second input beside the
|
|
tree comparison, and the toast says which block and why the file won. The flag stands for exactly as
|
|
long as those bytes are the file's, so a later lap of the debounce over the same unsavable block
|
|
says nothing more, and it is deliberately kept out of the question the debounce asks, since a
|
|
refused edit is not a difference another write could fix and re-arming for it would be a loop.
|
|
|
|
Where the body starts is the other: `---` on the first line of a
|
|
file is not a rule, it is the opening delimiter of frontmatter, and everything down to the next one
|
|
stops being markdown. That check is asked only of a body whose first three characters could open a
|
|
delimiter, and only when the body really is at the first byte of the file. A body with frontmatter
|
|
coming in front of it was never in danger, and running the defence there did the damage it exists
|
|
to prevent, because the blank line it pushes the body down by is handed back as part of the
|
|
frontmatter and written again on the next save. `serializeMarkdown` is the one thing that knows
|
|
whether a prefix is coming, so it is what tells the body writer.
|
|
|
|
One inline shape has no spelling either, and the writer settles it rather than the editor. A hard
|
|
break is a line ending inside a block, and there is no line left for one to start at the end of a
|
|
block, so a break with nothing under it is written as nothing. It used to be written as a backslash
|
|
on a line of its own, which reads back as a literal backslash in the user's words: a character they
|
|
never typed. That answer is in the serializer and nowhere else on purpose, because the trailing
|
|
break has more than one way in. Shift+Enter at the end of a paragraph is the obvious one, and is
|
|
still allowed, since it is a line somebody is halfway through typing and the next character makes it
|
|
a real break. Deleting the text out from under a break that was legal where it was put is the other,
|
|
and no keystroke guard sees that one at all. The editor refuses only the level 1 and 2 heading,
|
|
where a trailing break also takes the setext underline with it, and that refusal is about the
|
|
marker, not about the backslash.
|
|
|
|
Reading costs about eight times what writing does, and this editor saves half a second after the
|
|
user stops typing, so neither ladder runs on a block that has nothing in it to get wrong. A block
|
|
reaches the outer one only when it holds a line ending somewhere markdown has no way to write one,
|
|
or bytes mdast did not choose, and it reaches the inner one only when it holds a url that could be
|
|
written short. On the real corpus and on a 200 KB document of ordinary prose that is no blocks at
|
|
all: a save is 24 ms and no reparse happens. The shape that pays is a document with a bare url in
|
|
every paragraph, which costs two trips through the parser per block. Measured in Chromium on three
|
|
large real files, a save is 26 ms for the 179 KB yt-dlp README, 56 ms for the 266 KB
|
|
opentelemetry-js changelog and 91 ms for the 251 KB node changelog, each byte identical on the
|
|
second save.
|
|
|
|
## The block lanes
|
|
|
|
A node's shape is in the frozen schema and is turned into a TipTap extension mechanically. What a
|
|
node does, its ProseMirror plugins, its node views, its keymap and its input rules, is not derivable
|
|
from a shape and does not belong on a generated extension, so each of the five blocks with real
|
|
behaviour is one extension in one file, listed once in a registry that the extension list spreads
|
|
without knowing what is in it. Tables, syntax highlighting, maths, mermaid and toggles are then five
|
|
files that can be worked on at once, each exporting its extension and whatever commands the editor
|
|
handle delegates to when a toolbar button is pressed.
|
|
|
|
The toggle is the one whose behaviour is mostly outside ProseMirror. It is a real `<details>`, and
|
|
its summary row is chrome rather than content: the row is declared not editable, the title inside it
|
|
is declared editable again, and every keystroke in that island is turned into an ordinary
|
|
transaction that writes the `summary` attribute. Native disclosure is cancelled and `open` is
|
|
written by a command for the same reason, since both of those are bytes in the file and a browser
|
|
flipping an attribute behind the document's back is a title or a state the next save has to guess
|
|
at. Cancelling the click is not the whole of it either: a disclosure is a control, so the browser
|
|
works one from the keyboard too and sends the row a click for every space typed anywhere inside it,
|
|
which was every other space in a title flipping the toggle and writing `open` to disk. The flip asks
|
|
for a pointer press, or for the row itself holding the keyboard, and takes nothing else as consent.
|
|
|
|
Being chrome has a second consequence that reaches further than the node. A caret in the title is
|
|
not in the document at all, so ProseMirror's selection stays wherever it last was, and a toolbar
|
|
button pressed while a title is being typed runs its command against a paragraph the user is not
|
|
looking at. Rather than give the title a selection, which makes Horizontal rule and Insert image
|
|
replace or split the toggle, the file refuses: a transaction that changes the document is thrown out
|
|
while a title holds the caret, bar the two the title's own surface makes, and the caret is put back
|
|
in the title on the frame after TipTap's own focus lands. The toolbar draws itself disabled for the
|
|
same condition, read off the page rather than off a transaction, because clicking into a title
|
|
dispatches none.
|
|
|
|
And a toggle only ever sits among the document's own children, because that is the only place the
|
|
bridge pairs one. A `<details>` inside a quote or a list item goes to disk as a `<details>` inside
|
|
that container and comes back as a single raw block: the bytes survive, and both constructs stop
|
|
being editable. So a transaction that puts a toggle below the top level is refused, and the ranges
|
|
it is asked about are widened to the whole top level block first, since a wrap rewrites only the two
|
|
markers it puts either side and the thing it moved a level down is in the gap between them.
|
|
|
|
The order in that registry is load bearing rather than alphabetical. ProseMirror resolves a node
|
|
view first plugin wins by node name and gives a node view constructor no way to decline, and a
|
|
mermaid diagram is not a node of its own but a code block whose language happens to be `mermaid`. So
|
|
mermaid owns the code block node view outright and draws an ordinary fence for every other language,
|
|
while the highlighter contributes decorations only, which is what it wanted anyway: highlighting is
|
|
inherently a decoration and never a transaction that touches the text.
|
|
|
|
Two rules cut across all five. A command that has nothing to act on where the cursor is returns
|
|
false and does nothing, which is what a button pressed in the wrong place should do. And anything
|
|
that puts a node in the document asks first whether a node can go there at all: a table cell holds
|
|
inline content and is isolating, and ProseMirror asked to insert a block into one will split the
|
|
table around it and leave a row with no cells behind, which is a table the writer turns into three
|
|
blank lines. A fence and a raw block are the same question from the other side, since both hold
|
|
bytes that are the user's and an insert cuts them in half.
|
|
|
|
That guard is `src/editor/fits.ts`, and it now answers two questions rather than one, because the
|
|
first one turned out to be half the problem.
|
|
|
|
The first is about a node: can this thing go here. `fits` answers for one position and is the rule
|
|
itself. `placeable` asks it for a whole selection, which is the question a command actually has: a
|
|
selection has two ends, and a rectangle of table cells dragged out has neither of them anywhere a
|
|
caret would be, so it is refused outright whatever the type. An inline formula fits a cell perfectly
|
|
happily, which is right for a caret and catastrophic for a drag, and that gap is how one insert
|
|
emptied six cells of somebody's table. `place` is the guard and the insert in one call, so a command
|
|
written through it has no insert in it to forget to guard.
|
|
|
|
The second is about a command: may this edit happen here at all. Nothing asked that for two
|
|
milestones, and a block conversion is where it showed: `setNode` asks whether the new type fits and
|
|
never asks what the old block was, so the Heading tool rewrote a raw block's own bytes as escaped
|
|
markdown, and the Paragraph item, which TipTap answers by lifting once the block is already a
|
|
paragraph, deleted the callout around the caret along with its label. `change` is that half. It
|
|
refuses over a raw block outright, and otherwise it builds the transaction without dispatching it,
|
|
looks at what it did, and throws it away when a callout or a toggle that was there is gone, or when
|
|
a finished line break has landed somewhere the file swallows. Answering on the finished transaction
|
|
rather than on the command is the point: there is no list here of which commands lift, so a command
|
|
that starts lifting in a later TipTap is refused on the day it does. Its one exemption is the button
|
|
that names the wrapper, since the Toggle button pressed inside a toggle is meant to remove it.
|
|
`markable` and `breakable` are the same shape for the Link tool and for Shift+Enter.
|
|
|
|
Four things reach these guards from outside the toolbar. A pasted image asks before the bytes are
|
|
sent to be written, so a paste that cannot land leaves nothing in the assets folder, and again after
|
|
the write returns, because the caret is the user's during a round trip to disk; the Insert image
|
|
tool asks the same question through `canInsertImage`, since it has to write the picture before it
|
|
has a path to insert. A plain text paste is not refused anywhere, since text goes everywhere, but
|
|
where paragraphs would tear the block open it arrives as text instead, and a drop of blocks where
|
|
blocks do not fit is refused outright. And "--- " typed at the start of a line is the one typing
|
|
rule that inserts a node beside the caret rather than wrapping or retyping the block the caret is
|
|
in, so it is the one typing rule that has to ask; the others are operations ProseMirror declines on
|
|
its own.
|
|
|
|
`src/editor/fits.test.ts` is what holds all of that together, and it enumerates rather than lists.
|
|
It asks the running editor for its entry points: every method on the editor handle, off
|
|
`createCommands`; every method on the find handle, off `createFind`; every chord, read off every
|
|
extension's `addKeyboardShortcuts` the way the extension manager reads them; every ProseMirror prop,
|
|
read off every extension's plugins; and every prop the component puts straight on the view, off
|
|
`createEditorProps`. That last channel is neither an extension nor a handle and was invisible to the
|
|
enumeration until it was lifted out of the component, which is exactly the shape of gap this file
|
|
exists for. Each entry declares a family and each hostile context answers for every family, so a new
|
|
family forces a column and a new context forces a row, both at compile time. Three checks then apply
|
|
to every cell whatever its row says: the number of raw blocks is unchanged, no callout or toggle is
|
|
lost by anything not asked to lose one, and no toggle ends up below the top level.
|
|
|
|
Nothing in that file calls a handler. Every entry point goes through `EditorView.prototype.someProp`,
|
|
borrowed off prosemirror-view rather than modelled here, because the walk it does is the question:
|
|
the props the component put on the view, then the direct plugins, then the state's plugins in order,
|
|
first answer that is not false. Calling a guard by name and asserting what it answers proves nothing
|
|
about whether the running program asks it, and four guards in this project's history were shipped
|
|
with a green test of exactly that shape. What the unit suite still cannot do is construct a view at
|
|
all, because vitest runs in node with no DOM, so the event object and the parsed slice are built by
|
|
hand there. `tests/clipboard.spec.ts` is the other half: a real `EditorView` in Chromium, a real
|
|
mouse drag across a rectangle of cells, a real Cmd+V off the system clipboard, and assertions only
|
|
about what is on screen afterwards. It is the only place in the repository where the whole chain
|
|
from a key to a byte is real.
|
|
|
|
Where a guard sits in the plugin list is part of the guard. ProseMirror offers an event to the
|
|
plugins in order and takes the first answer that is not false, and prosemirror-tables installs a
|
|
paste handler that claims every paste made while a rectangle of cells is up, replacing the content
|
|
of every cell in the rectangle with whatever the slice holds. It sat at index nine and this app's
|
|
clipboard plugin sat at fifteen, so for two rounds the app's paste guard was written, documented,
|
|
unit tested and asked about nothing. One word on the clipboard replaced six cells; an image, which
|
|
carries no HTML for ProseMirror to parse and so arrives as the empty slice, emptied them. The
|
|
clipboard extension now declares a priority above every other extension in the tree, which puts it
|
|
at the head of the list whatever order the extension file lists things in, and being first is
|
|
asserted rather than believed: `src/editor/fits.test.ts` reads the built plugin list, computes the
|
|
index of every plugin claiming a paste or a drop, and fails if anything is in front. A guard is also worth nothing when the gesture it is watching for produces no event to answer, and
|
|
that is the sibling of the ordering fault rather than a different kind of mistake. Typing over a
|
|
rectangle of cells is refused through `handleTextInput`, which prosemirror-view offers only for a
|
|
character the browser was about to insert. An IME produces none: the browser writes the composition
|
|
into the DOM itself and prosemirror-view reads the result back, and on the way there its own
|
|
`compositionstart` replaces the rectangle with whatever a DOM range spanning cells makes, because
|
|
prosemirror-tables hands back a cell selection only while a mouse drag is still down. What follows
|
|
is either that range collapsed on to the whole content of one cell and replaced, or a text selection
|
|
left spanning cell boundaries and replaced across them, which joins the cells and rows away and
|
|
hands the writer a table whose rows disagree in width. Neither reaches the guard, and the second
|
|
never reaches it by construction, since a change crossing a textblock is dispatched without the
|
|
offer being made. Claiming the event does not help, because a composition cannot be cancelled and
|
|
returning true only stops ProseMirror's own bookkeeping. What is decidable is where the composition
|
|
lands, so the rectangle is collapsed to a caret at the end of its anchor cell before one can start,
|
|
and false is returned so the library's own handler still runs against that caret. Being first
|
|
also means standing aside deliberately, for the one paste the library does better: cells copied out
|
|
of a table and pasted into one, recognised with prosemirror-tables' own predicate rather than a
|
|
reimplementation of it. Emptying cells is a real thing to want and it is Backspace.
|
|
|
|
There is no table op for the header row for a related reason. GFM has exactly one header row, it is
|
|
the first one, and there is no spelling for a table without one, so a toggle would offer an edit the
|
|
file cannot hold and the next open would silently take back. The same rule is why a new formula is
|
|
created holding a placeholder rather than nothing: an empty formula is `$$$$` on disk, which reads
|
|
back as four characters of text, so a box the editor draws and the file cannot hold is a box that
|
|
disappears on the next save.
|
|
|
|
Not every change to the document is a change to the file, and the block layer is where that first
|
|
became true. Dragging a column edge writes a width on to every cell in the column, which is a real
|
|
ProseMirror transaction and marks the buffer dirty, and GFM has nowhere at all to put a column
|
|
width. Sniffing for that particular transaction would be fragile, so the question is asked of two
|
|
trees instead: a walk comparing type, text, marks, child count and attributes, skipping the handful
|
|
of attributes the serializer never reads. It short circuits on identity, which is every subtree a
|
|
transaction did not visit, so it costs nothing per keystroke. The allowlist is deliberately short
|
|
and each entry has its reason written beside it, because an attribute wrongly called insignificant
|
|
is a user edit that never gets saved.
|
|
|
|
Types are compared by name and marks by name and attributes, never by object, and that is not a
|
|
shortcut. The two trees this walk is handed are never built on the same schema: the disk side comes
|
|
off the bridge, which parses against the frozen schema, and the live side is bound to the schema
|
|
TipTap generates from those same specs and which the editor rebinds every opened document on to.
|
|
Two instances over one set of specs means every type object in one differs from its twin in the
|
|
other, so `a.type !== b.type` and `Mark.sameSet` were both true of every pair this function had
|
|
ever been given, and everything behind them, the attribute allowlist included, was unreachable.
|
|
Name is also the right thing to compare rather than a way around that: the serializer dispatches on
|
|
`node.type.name` and `mark.type.name` and reads nothing else off a type.
|
|
|
|
Which two trees is the part that had to be got right twice. Asking whether this keystroke changed
|
|
anything since the last one makes dirty a count of transactions rather than a fact about the file:
|
|
the answer can only ever go up, so an edit and its undo inside one debounce still wrote, and on a
|
|
hand written file the save path's own byte comparison cannot catch it, since that file's serialized
|
|
bytes differ from its own bytes from the moment it opens. So `src/document.ts` keeps `diskDoc`
|
|
beside `diskText`, the tree whose serialization is the bytes currently on disk, and
|
|
`differsFromDisk` asks the question of that rather than of the previous keystroke. The two move
|
|
together through one function, so they cannot drift into one guard sending a write and the other
|
|
holding it back. Behind that the save path still serializes and compares the result against the
|
|
bytes it last saw on disk, which catches an edit that really did happen and really did come out
|
|
identical. Between them they are what stops a gesture that moved a line on screen, or a typo typed
|
|
and taken back, from putting a whole file diff in somebody's git status.
|
|
|
|
## Mermaid, and what is not in the main bundle
|
|
|
|
Mermaid is about 700kB before its per diagram chunks, which are several megabytes between them, and
|
|
almost no document has a diagram in it. It is imported dynamically the first time a diagram is
|
|
actually drawn, not when one is merely present with the caret inside it, so the library is a chunk
|
|
of its own and the main bundle contains none of it. Rendering is asynchronous, so the node view
|
|
draws the fence first and swaps the SVG in when it arrives, and it does that without dispatching a
|
|
transaction: nothing mermaid does reaches the document, and a diagram that fails to parse stays as
|
|
the code the user typed with the parser's complaint beside it. Nothing it needs is fetched over the
|
|
network, which matters because the app runs under a strict CSP with no `connect-src` for anything
|
|
but the Tauri IPC. KaTeX is the opposite trade and is bundled outright, since a formula is small,
|
|
common, and has to render synchronously; its fonts are emitted as files rather than inlined, because
|
|
the CSP's `font-src` is `self` and refuses the `data:` URI a small enough font would otherwise
|
|
become.
|
|
|
|
## The dto.rs and ipc.ts contract
|
|
|
|
Rust and TypeScript meet at exactly one seam: `src-tauri/src/dto.rs`, a set of
|
|
`#[serde(rename_all = "camelCase")]` structs, and its hand-written mirror `src/ipc.ts`. Both sides
|
|
are built against that shape rather than against each other, which is what lets the filesystem
|
|
layer and the markdown bridge be built independently of one another: the tree, the read and write
|
|
commands and the index queries only need their DTO agreed, not implemented, before anything above
|
|
them is built against a stand-in of the same shape. The file is frozen by convention: a command's
|
|
Rust body can change freely, but adding, renaming or retyping a field is a decision made once, in
|
|
both files, in the same change.
|
|
|
|
## The dev IPC mock
|
|
|
|
`pnpm dev` runs the Vite dev server on its own, with no Tauri process behind it and no
|
|
`window.__TAURI_INTERNALS__` for `@tauri-apps/api` to find. `src/ipc.ts` checks for that bridge and,
|
|
when it is absent, swaps in a mock implementation of the same command surface backed by an
|
|
in-memory fixture, rather than throwing or leaving the screen blank. That is what lets the
|
|
Playwright suite in `tests/` (`pnpm test:ui`) drive the real editor, the real store and the real
|
|
components in plain Chromium without a compiled Rust binary anywhere in the loop, and it is also
|
|
what makes iterating on the editor fast day to day: no Rust recompile between changes. The mock is
|
|
exactly as wide as `dto.rs`, never wider, so a command the mock cannot answer is a command that has
|
|
not been added to the contract yet, not a gap in the mock to be patched around.
|
|
|
|
That fixture is also how a test reads what a save actually wrote. `tests/disk.ts` puts the shim and
|
|
the fixture behind one import, and `tests/bytes.spec.ts` uses it to type one character into a
|
|
running editor and then ask the file what happened. Every data loss bug this project has had lived
|
|
between the keystroke and the serializer, where a unit test that builds a document by hand cannot
|
|
see it, so that suite is deliberately about bytes and deliberately short: one test per way the app
|
|
has been caught rewriting something nobody touched.
|
|
|
|
The corpus under `src/markdown/corpus/` is globbed by folder rather than by a list of folder names,
|
|
so a fixture written for one test is inside every sweep the moment it is added. That is the whole
|
|
reason a sweep is worth having, and naming folders meant the twenty hardest files in the corpus sat
|
|
outside four of the gates that were supposed to cover them.
|
|
|
|
## The SQLite index
|
|
|
|
SQLite through `rusqlite` with the `bundled` feature, so there is no system SQLite dependency, and
|
|
with FTS5 compiled in as a side effect of that same feature rather than a separate cargo flag. The
|
|
database lives in the app data directory, never inside any folder the user opened, and it is
|
|
derived state rather than a source of truth: every row is rebuilt from the markdown files on disk,
|
|
so deleting it costs nothing but the time to walk the open roots again. It is kept current by the
|
|
same `notify`/`notify-debouncer-full` watcher the tree uses, debounced because a git checkout or
|
|
another editor's save fires a burst of filesystem events for what is really one change. The index
|
|
answers three things a plain file tree cannot: quick open by filename and path on `Cmd+P`, full text
|
|
search across every open root on `Cmd+Shift+F`, and the backlinks section appended to a document,
|
|
a reverse lookup of every relative markdown link elsewhere that resolves to the file currently open.
|
|
|
|
Derived state that only rebuilds at launch has to survive the session, and the worker behind it was
|
|
written as though it could not fail. It ran a bare loop over its queue, so one panic ended indexing
|
|
for as long as the app stayed open: the receiver dropped, every later send was discarded by a `.ok()`
|
|
that never looked, and search, quick open and backlinks went on answering from a frozen snapshot
|
|
with nothing on screen to say so. There was a reachable way to reach it, too, since a document is
|
|
free to contain the control character the search snippets are marked with, and a line holding one
|
|
and nothing else made the snippet slice start after it ended. The panic fired inside the search
|
|
command, which holds the connection across its whole row loop, so it poisoned the mutex and took the
|
|
worker with it. The loop now catches a panic, clears the poison, says so in the index status and
|
|
takes the next job, and it drains its queue before acting on it rather than one message at a time,
|
|
because the watcher's report that the kernel dropped events is a full walk of the root and hundreds
|
|
of those can pile up behind a long build. The drain keeps the last word about each path and never
|
|
reorders, which is the rule the debouncer already applies inside one batch.
|
|
|
|
The watcher had the same shape of gap about its own root. Detecting that the open folder had gone
|
|
rode on an event arriving for something inside it, because `notify` does not ask FSEvents to watch
|
|
the path to the root and discards anything above it, so renaming or deleting a parent directory was
|
|
never noticed at all and the watch was left holding a stream on a path that no longer existed. A
|
|
watchdog beside each watch stats the root on the debounce tick instead, which is one call and does
|
|
not depend on any event being delivered by any backend.
|
|
|
|
The link rewrite sweep is the one consumer that deliberately does not share the walk. The tree and
|
|
the index honour `.gitignore` because a sidebar full of build output and a search box full of
|
|
vendored READMEs are both worse than those files being hidden, and the invariant that a file the
|
|
tree hides is a file no search result can open is worth keeping for both. The sweep is not display,
|
|
it writes to the user's files, and a `.gitignore` is a statement about version control rather than
|
|
about whether something is a document: a relative link inside an ignored draft is one the user still
|
|
follows, and leaving it pointing at a path this app is the one that moved is a break nobody finds
|
|
until they follow it. So it gets its own walk with the git sources off and everything else on, the
|
|
four always skipped folders included, and it is bounded by a document count it reports rather than
|
|
by a depth it would have to hide.
|
|
|
|
## Order of work
|
|
|
|
The filesystem layer and the markdown bridge are the two things everything else depends on, and
|
|
neither is proven by the other, so the first milestone is Rust: roots, the tree, atomic read and
|
|
write, the watcher and trash, built from the start against a frozen `dto.rs` and `ipc.ts`, with the
|
|
dev IPC mock standing in for it on the TypeScript side. The second is the bridge itself: parsing,
|
|
the raw-node slicing that makes the round trip byte identical, frontmatter separation and
|
|
serialization, proven with round-trip tests before a single line of editor UI exists, because a
|
|
round-trip bug is obvious in a plain text diff and invisible once it is glued to a rich text view.
|
|
The third is the WYSIWYG editor: TipTap wired to the bridge, the sticky bottom toolbar, callouts,
|
|
toggles and image paste into `assets/`. The fourth is the SQLite index: the schema, the
|
|
watcher-driven indexer, quick open, full text search and backlinks. The fifth is the rest of the
|
|
shell: multiple roots open at once, the tree showing every file including the greyed-out ones that
|
|
open in the system default app, and settings and the updater.
|