How a keystroke becomes markdown on disk, how markdown becomes styled text on
screen, and how the formatting toolbar decides what state to show. Covers the
iOS live editor (iosApp/SteelNotes/Features/NoteEditor/) and the KMP editor
core (shared/src/commonMain/kotlin/com/steelnotes/editor/).
| Layer | File | Responsibility |
|---|---|---|
UnifiedEditorView + Coordinator | Editor/UnifiedEditorView.swift | SwiftUI ⇄ UIKit bridge. UITextViewDelegate: routes every keystroke, owns the isUpdating re-entrancy guard and the lastReconciledDisplay diff baseline. |
UnifiedEditorViewController | Editor/UnifiedEditorViewController.swift | UIKit shell: outer UIScrollView + header fields + non-scrolling SelfSizingTextView. Selection mapping (safeSelection), caret scrolling, drag/drop. |
MarkdownEditorStorage | Editor/MarkdownEditorStorage.swift | **The only writer of markdownSource.** Owns the canonical markdown string, the OffsetMap, undo history, and all display rebuilds. |
NoteTitleTextView + TitleCoordinator | Editor/NoteTitleTextView.swift, Editor/UnifiedEditorView.swift | The note title. A non-scrolling UITextView so long titles **wrap** instead of ellipsizing (a UITextField cannot). Its own delegate, deliberately separate from Coordinator, whose UITextViewDelegate methods all assume bodyTextView. Return moves focus to the body; pasted newlines flatten to spaces. |
OffsetMap | Editor/OffsetMap.swift | Bidirectional source-offset ⇄ display-offset mapping, built from visible / hidden / attachment segments. |
ParagraphBuilder | Editor/ParagraphBuilder.swift | Renders one markdown line → NSAttributedString + offset segments. Hides syntax markers, styles list prefixes, creates attachments. |
MarkdownLineClassifier (KMP) | editor/MarkdownLineClassifier.kt | The single definition of the **line grammar**: blank / image / transclusion / rule / fence / quote / heading / checkbox / ul / ol / paragraph, plus list prefix + ordinal + heading level. |
InlineTokenizer / InlineSerializer (KMP) | editor/InlineTokenizer.kt | The single definition of the **inline grammar**: flat styled runs (bold / italic / highlight / code), atomic tokens (wikilink, link), marker tokens. |
FormattingEngine (KMP) | editor/FormattingEngine.kt | Pure text transforms: toggle bold/italic/highlight, toggle list, indent/dedent, marker-aware deletion. Takes (text, selection) → returns (newText, newSelection). |
MarkdownASTCursor | Editor/MarkdownASTCursor.swift | Formatting state at the caret (drives toolbar) via the same KMP tokenizer, plus a local bullet-mode line check. |
EditorToolbarController + FormattingToolbarManager | Editor/EditorToolbarController.swift, Components/FormattingToolbar.swift | Toolbar buttons, active-state flags, keyboard tracking. |
Two invariants define the design:
1. Resource is authoritative. The display attributed string and the offset
map are always derived from markdownSource (never patched
independently, except the audited native fast path below). "Derive, don't
patch."
2. One grammar. Line classification and inline tokenization live in KMP
and are shared by rendering, toolbar state, Enter handling, and formatting
actions — they cannot disagree with each other.
Every keystroke enters through
Coordinator.textView(_:shouldChangeTextIn:replacementText:)
(UnifiedEditorView.swift:215),
which picks one of four routes:
```
keystroke
│
├─ deleting an attachment char? → mutate source line, setMarkdownSource, return false
├─ selection spans an attachment? → applyDisplayEdit (full re-derive), return false
│
├─ NATIVE FAST PATH (plain char, no grammar chars "*=`#>![]-", not "\n",
│ not marker-adjacent, inside one visible run)
│ → return true: UIKit mutates the text view itself.
│ textViewDidChange then DIFFS textView.text against
│ lastReconciledDisplay and patches the same delta into
│ markdownSource + offset map (reconcile()). This also catches
│ autocorrect / predictive-text mutations that never went through
│ shouldChangeTextIn.
│
└─ INTERCEPTED (grammar char, Enter, deletion, marker-adjacent edit)
→ return false; we mutate storage ourselves:
• Enter → performReturnKey (list continuation, wrapper split,
empty-item dedent/clear — see below)
• everything else → editorStorage.applyDisplayEdit
```
applyDisplayEdit maps the display range to a source range through the
OffsetMap, splices the replacement into markdownSource, and calls
rederiveDisplay:
rebuildParagraphs): for a plainsingle-line paragraph whose previous tokens are cached, the new line is
re-tokenized (KMP, authoritative — the tokens are never patched) and diffed
against the cache (TokenDiffer, shared Kotlin). When only a small window
of plain text/marker tokens changed, just that window's attributed runs,
offset-map segments, and display characters are spliced — the rest of the
paragraph is proven unchanged and left alone. Any miss (atomic or
zero-width tokens in the window, non-paragraph line, stale cache) falls
through to the scoped rebuild.
no fence/image/transclusion: only that paragraph's display + segments are
rebuilt (rebuildParagraphs).
rebuildAll) otherwise: classify every line (KMP), runParagraphBuilder per line, rebuild the whole attributed string and offset
map, and replace the text storage wholesale.
Deletions off the fast path go through FormattingEngine.deleteRange, which
is marker-aware: it deletes styled-run content and re-serializes, so a
backspace can never orphan half of a ** pair. Deletions interior to a
single text run (or ending flush at its boundary, away from empty style
slots) take an early-out inside the engine — a plain splice proven
byte-identical to the full path by the splice-parity tests — so typical
backspaces skip the decompose/re-serialize cost. When the deleted range spans a
newline (a whole item removed, or a join that pulls a blank/nested line onto a
run) it also renumbers the affected ordered run — deleting 2. two from a
1./2./3. list closes the gap to 1./2.. Content-only deletions are left
byte-for-byte, so a list authored 1., 5., 9. survives an in-item backspace.
Only a link's name is on screen ( and the tail are hidden
segments), so deleteRange judges the deletion against the name rather than
the raw text — for both plain links and steel:// note links:
survives (hello minus ell → ho);
character — the deletion mapping is left-biased, so this arrives as a range
inside the name;
cannot select what they cannot see;
untappable [](…).
The rebuilt raw is deliberately not title-sanitized: this runs mid-edit,
and collapsing whitespace would rewrite text the user is still typing.
A link's ](…) is hidden, so the display has no position past a link —
at the end of a line the caret cannot get out, and typing could only ever
grow the name. So an insertion at the end of a link's name lands after the
whole link: OffsetMap.Segment.closesAtomicLink marks the trailing hidden
segment and sourceOffsetForInsertion steps over it.
The name stays editable from one character earlier, which is the same trade
the wrapper-escape machinery makes for bold. The alternative considered
— padding the resource with a trailing space so there is something to tap past
— was rejected: it mutates the user's markdown for a UI problem, accumulates
on every link, and syncs everywhere.
A photo, image row, or transclusion card is a whole block of content behind a
single display character, so one stray backspace must not destroy it. The
first backspace only arms it (Coordinator.pendingAttachmentDelete): the
attachment is selected and its view draws a 2pt blue border. Only a second
backspace at the same range deletes. Moving the caret, typing, starting a
drag, or an external body update cancels the arming.
The armed flag lives on EditorAttachment, not the view — UIKit owns view
lifetime and may rebuild one at any time, so the providers re-apply the
highlight in loadView via registerHighlightTarget, which also captures
the view's resting border so cancelling restores it.
Deleting the reference does not immediately delete the file: the attachment
GC holds an unreferenced file for a grace period (AttachmentGcPolicy) so
undo — which restores the markdown reference but never the bytes — still
works.
A GFM pipe table renders as one attachment per row, not one attachment for
the whole block. That is the decision the rest of the feature falls out of: a
row is a display character, so dragging a selection across the table
highlights it row by row through the same syncAttachmentSelectionBorders
that borders photos, and deleting rows reuses the attachment delete path.
The header row is the one place the mapping is not one source line per
display row: it owns both the header line and the | --- | --- |
delimiter under it, with the newline between them and the delimiter itself
emitted as hidden segments — the same trick the image row uses to fuse
several source lines into one visual line.
MarkdownTable (shared KMP) is the single definition of what a table is.
MarkdownLineClassifier.classify promotes a pipe paragraph to TABLE_ROW /
TABLE_DELIMITER only when the two-line header+delimiter pairing holds, which
is why that pairing lives in classify beside the code-fence state rather
than in the stateless classifyLine. The read-only article renderer parses
the same model into a TableBlock and draws it as a SwiftUI Grid.
Every edit is derive-don't-patch: TableGroup holds the parsed table plus the
measured column widths and the shared horizontal scroll offset, a mutation
returns a new MarkdownTable, and
UnifiedEditorViewController.applyTableEdit re-serializes it over the
table's source lines and lets the storage rebuild. Nothing edits the rendered
grid in place.
Cell text commits on end-editing (Return, focus loss, or any structural
action) — never per keystroke, which would tear down the cell's field
mid-word. While one cell is being edited it shows its raw markdown; every
other cell renders its formatting with the markers hidden, as everywhere else
in the editor.
Two v1 rules worth knowing: deleting a selection that includes the header row
blanks the header and keeps the remaining rows (only a selection covering
every row deletes the table), and | is in the fast-path grammar set, since
typing one is how a paragraph becomes a table.
An image or embed only renders as one while it owns its whole line, so
merging it into a neighbour destroys it: hello + !alt becomes
hello!alt, which no longer classifies as IMAGE and re-parses as
text plus an inline link — the photo silently turns into a blue link, on
disk.
The rule is enforced in FormattingEngine.deleteRange, which every
display-driven deletion funnels through: a line break beside a surviving
image/embed line survives the deletion even when the selection covered it,
while everything else in the selection still goes. Deleting such a line
whole is unaffected — only merging is forbidden — so backspace, range
delete, forward delete and paste-over all inherit the protection. Fenced code
is exempt, since an image line inside a fence is just text.
On top of that, the delegate handles the caret UX for the plain-backspace
case: with the caret at the start of an image/embed line it steps back to the
end of the previous line rather than sitting still. A blank line above is
still removable — the caret lands on it, and the next backspace collapses
it.
verifyConsistency checks after every commit that
offsetMap.totalSourceLength == markdownSource.length and
offsetMap.totalDisplayLength == textStorage.length. It runs in all
configurations: a violation self-heals with a full rebuild from the
canonical resource and logs an os_log fault once per session (a desync left
in place silently corrupts every later edit). Tests install
consistencyViolationHandler to record the failing shape instead of healing.
Coordinator.performReturnKey (UnifiedEditorView.swift:393):
1. Inside a code fence → plain \n.
2. Active inline wrappers at the caret → close them before the newline and
reopen after (foo| + Enter → foo\n****).
3. On an empty heading line (# with no content) → clear the prefix,
leaving a blank line. A heading with content needs no branch: it has no
continuation prefix, so it falls through to the plain \n at step 5 and
the user lands in body text — which is what they meant to write next.
4. On a list line (per the KMP classifier):
\n + continuation prefix (- , n+1. ,- [ ] , preserving leading indent);
5. Otherwise plain \n.
After any programmatic mutation, Coordinator.commitEditorState() pushes
markdownSource into the SwiftUI bodyText binding via pushBodyToBinding,
which stamps the bodyRevision binding with the coordinator's generation
counter, and refreshes lastReconciledDisplay. Fast-path keystrokes do the
same push at the end of textViewDidChange.
From there (NoteEditorContent.swift):
fields.bodyText changes → .onChange(of: fields) → scheduleAutoSave()
(debounced) → SharedCoreService.applyEdit / createNote → KMP vault layer
writes the .md file (frontmatter + body) → indexer + sync pick it up.
Coordinator.reconcileRenderedBody lets updateUIViewController distinguish
an external change (sync landed → reload editor) from the editor's own binding
lag (push editor state out, don't clobber). SwiftUI delivers renders in order,
so a render whose bodyRevision is behind the coordinator's counter was
captured before the last keystroke and is stale; one carrying the latest stamp
saw that keystroke, so any difference in it came from outside the editor.
rebuildAll / rebuildParagraphs render each line:
1. Classify the line with KMP MarkdownLineClassifier (one pass tracks
code-fence state document-wide).
2. Build with ParagraphBuilder.build, which emits an attributed
fragment plus OffsetMap segments:
hidden segments (displayLength 0): syntax the user shouldn't see —leading indent spaces on list lines, > , inline markers * ==
` ``, wikilink brackets, link URLs.
visible segments (1:1): content text, list prefixes (- , 1. ,- [ ] stay visible but styled grey via Styles.listPrefix, tagged
with .steelListPrefix). The grey prefix is inert: styling clamps
off it (FormattingEngine.toggleFlag never wraps a prefix in markers, so
a bulleted line can't be turned into - item and lose its list-ness),
and the caret snaps out of it (selectionSnappedOutOfListPrefix) so
typing lands in the content, not ahead of the marker.
attachment segments (source line → 1 display char): images,image rows, transclusion cards (EditorAttachment +
NSTextAttachmentViewProvider).
3. Inline styling: content is tokenized by KMP InlineTokenizer into flat
styled runs; flags compose (==x== = one run, bold+highlight).
Markers become hidden segments; runs get composed
font/background attributes.
4. Paragraph styles control block indentation
(ParagraphBuilder.Styles): lists use
firstLineHeadIndent = indentLevel × 20 and a headIndent for wrapped
lines; blockquotes indent 20pt with a vertical bar drawn by
SelfSizingTextView.
This is the hidden-marker WYSIWYG model: the display never shows markdown
syntax, and the offset map is what keeps caret positions honest between the
two coordinate spaces. A zero-length caret at a hidden-marker boundary is
disambiguated by sourceNSRangeForEdit (insertion-aware mapping) plus the
one-shot pendingInsertionSourceOverride ("wrapper escape") that a toggle
sets when it parks the caret just past a span's closing markers.
The left format capsule is shown only while the body is being edited; the
undo/redo capsule is always visible. That decision is a stored flag,
EditorToolbarController.isEditingBody, moved only by the two focus edges
(textViewDidBeginEditing / textViewDidEndEditing → bodyFocusChanged).
Keyboard-frame notifications re-apply that flag but can never change it. They
used to derive it by polling bodyTextView.isFirstResponder, and a
notification landing while the responder chain was mid-handoff (selection
gestures raise their own responder) read "not editing" and hid the capsule for
the rest of the session — the note had to be closed and reopened to get it
back. Focus alone is still the test: with a hardware keyboard the reported
frame has no height, so keyboard height must not gate visibility either.
One asymmetric safety net: a live isFirstResponder promotes a stale false
back to editing. The reverse is deliberately absent — nothing may hide the bar
out from under an editing session except a real end-editing event.
The bar has exactly one vertical constraint: its bottom is pinned to the
editor view's bottom edge, and the constant is
EditorToolbarController.barClearance(keyboardHeight:safeAreaBottom:) —
max(keyboard height, home-indicator inset) + rowGap. The keyboard height is
the one keyboardChanged already derives from the
keyboardWillChangeFrame notification frame, so the only thing that can move
the bar is a keyboard notification the editor actually received.
It used to ride view.keyboardLayoutGuide.topAnchor at priority .required - 1,
capped by a required <= safeAreaLayoutGuide.bottomAnchor. Both halves of that
were failure modes: the guide is UIKit-owned and can quietly stop tracking an
open keyboard, and a 999-priority constraint is one the layout engine is free to
break — either way the bar resolved to the bottom of the screen, behind the
keyboard, with nothing in the editor able to tell. That is what the user saw as
"the formatting bar drops down under the keyboard a few seconds after I bold
something". Folding the home-indicator floor into the same constant removes the
second constraint too, so there is nothing left to conflict.
safeAreaChanged() re-applies the position as well as the scroll inset:
rotating changes the home-indicator inset (34 → 21) without producing any
keyboard notification.
Every caret move fires textViewDidChangeSelection →
EditorToolbarController.updateActiveStates():
1. vc.safeSelection() maps the display selection to source offsets — the
same mapping formatting actions use, so button state and button action
always see the same position ("mapping parity").
2. MarkdownASTCursor.formatting(cursor:selEnd:):
with the KMP tokenizer; a bare caret reports the flags of the run whose
content range contains it (preferring the preceding run at boundaries);
a selection reports a flag only when every selected run has it (so an
active button always means "tap will clear").
off / bullet / numbered): a deliberately localstring check on the caret's line (bulletModeAtCursor) — not the KMP
classifier, as a per-selection-change perf concession.
3. Flags land on FormattingToolbarManager (isBold, isItalic,
isHighlight, bulletMode, headingLevel), which restyles buttons; the
bullets button swaps its icon between list.bullet and list.number, and
the heading button relabels itself H / H1 / H2 / H3.
Programmatic edits run under isUpdating (no selection callback), so
commitEditorState calls updateToolbarActiveStates() explicitly.
textViewDidChangeSelection also keeps the interesting part of the selection
on screen, and which part depends on the selection:
scrollToCursorIfNeeded(), but only whenshouldAutoScrollToCaret() allows it (EditorAutoScrollPolicy): a touch
in flight on the body suppresses it, because scrolling mid-drag slides the
text out from under a caret drag and can cancel it.
Coordinator.movingSelectionEdge(previous:current:) resolves theedge that just moved (start moved and end didn't → start; otherwise end) and
only that edge is revealed. Revealing the selection start on every change
— what the caret path does — yanked the note back to the top of a long
selection the moment a handle drag extended it. The range path is not
gated on the caret policy: a selection-handle drag is a touch in flight,
and it is exactly the gesture asking the note to move.
Both paths end in scrollRectToVisibleIfNeeded(_:padding:), whose band/clamp
math lives in the pure EditorScrollGeometry.scrollTarget(...) (returns nil
when no scroll is needed).
Following the moving edge only scrolls while the finger is moving: a finger
parked at the edge fires no selection changes. SelectionAutoscrollController
covers that case — a zero-duration long press observes touches on the body
(simultaneous with everything, cancels nothing), and while a ranged selection's
touch sits inside a 44 pt zone at either end of the visible content (measured
from adjustedContentInset, so above the keyboard and toolbar) a CADisplayLink
creeps the scroll view and extends the selection to the text now under the
finger. UIKit's own drag-to-edge autoscroll never runs here because the body
text view doesn't scroll itself.
Tests: SelectionAutoscrollTests.swift (scroll target, moving edge, speed
curve, extension); the gesture glue itself is simulator-verified.
Buttons call EditorToolbarController.apply* → FormattingEngine (pure KMP
transform on (markdownSource, sourceSelection)) → vc.applyResult →
editorStorage.applyFormattingResult (undo snapshot, re-derive display, map
the returned resource selection back to display coordinates, park the wrapper
escape if the engine's caret isn't display-expressible).
The bullets button is a tri-state cycle driven by the current mode:
off → add unordered; bullet ⇄ numbered → switch kinds. Once a list is
active the button never removes it (that's backspace/dedent's job), and a
line switching to ordered continues numbering from the previous same-indent
ordered sibling. Indent/dedent prepend/remove two spaces; indenting an
ordered item resets it to 1. (new sub-list), dedenting renumbers it after
its new siblings. Backspace at a list line's content start steps the item
back: dedent one level when indented, otherwise remove the whole prefix
(Coordinator.performListBackspace).
A heading is a whole-line property, stored as genuine markdown (# , ## ,
### ). h4+ is deliberately unsupported: #### x never classifies as a
heading, so a fourth # stays literal text instead of silently producing a
level nothing downstream renders or cycles to.
Three redundant ways to apply one, all landing on the same KMP transform
(FormattingEngine.setHeading, which takes an explicit level — the callers
decide what "again" means):
# at a line start converts it. This needsno keystroke handling of its own: # is already a grammar char that forces
the intercept path, and editMayCompleteBlockPrefix already intercepts the
completing space, so conversion falls out of the normal re-derive.
H button cycling body → h1 → h2 → h3 → body(applyHeadingCycle), labelled H / H₁ / H₂ / H₃ to show the caret
line's current level. The label uses Unicode subscript digits in a single
font run: a second run with a negative .baselineOffset is the obvious way
to build it and UIButton silently drops the whole title when you do. The
retitle runs inside performWithoutAnimation — a system button crossfades
its title, and this one is retitled on every selection change that crosses a
heading boundary.
has it (applyHeading(level:)).
Headings and lists are mutually exclusive, and each button converts the
other's line rather than refusing it. Promoting a list item strips the whole
prefix (- [ ] included — a box without its bullet is meaningless) and its
indent. A heading is top-level structure, so it severs any ordered run it
lands in: the items below the new heading are a fresh list and restart at 1,
while the run above keeps its numbering (renumberOrderedRun treats a heading
like a blank line — it ends the run). The list button on a heading line strips
the hashes and applies the list, continuing the numbering of the run above it,
and the trailing renumber pass — now flowing through where the heading was —
pulls the restarted run below back into one sequence. Both are whole-line
structure, so either tap reads as
"make this line the other thing" — there is nothing to disambiguate and no
reason to make the user demote by hand first. Blockquotes, images,
transclusions, rules and fenced code are left untouched, and inside a fence
# x stays literal text.
A heading holds one inline style: highlight. Its own face carries the
hierarchy, so bold/italic/code inside one would compete with the thing it
exists to express — and a heading is top-level structure, so it takes no
indent either. That rule is enforced twice over: the engine refuses
(toggleBold/toggleItalic/toggleCode treat a heading line as
non-formattable; indent/dedent no-op on one, and their selection forms
skip heading lines while transforming the rest), and the toolbar renders B, I,
indent and dedent disabled rather than merely inactive, so the affordance
itself says "not here". Highlight, bullets and the H button stay live — the
one style that combines, the alternative structure, and the way back out.
Markers that arrive from elsewhere (## a b) are preserved in the resource
and render inert: buildHeading hides them as usual but honors only the
highlight flag, so the line reads as one uniform face and still round-trips.
Promoting a styled body line clears the markers a heading cannot hold, so the
line is what it renders as. Leaving a heading with Enter at the end of a
styled run steps past those closing markers rather than splitting them — the
ordinary close-and-reopen split would strand the line's real closers at the
head of the new line.
The #-prefix renders hidden (like > , unlike a list's grey - ): the
hierarchy is carried by size, weight and the space above, per
ParagraphBuilder.Styles.heading(level:) — h1 26pt bold, h2 21pt semibold
italic, h3 19pt bold. Size alone left h2 and h3 too easy to confuse, hence
the slant on h2; h3 is deliberately quiet, sitting just above body size and
leaning on the space above it rather than a loud face. (Small caps were tried
here and rejected in the field.) Display text is never transformed —
uppercased() would make the display diverge from the resource and break every
offset in the map.
Two gestures let the user out of a heading, and both matter because the caret
at a heading's display line start sits past its hidden prefix — so anything
typed there joins the heading and inherits its face. Backspace at that
position demotes the line to body before it will delete into the line above
(Coordinator.performHeaderBackspace); Enter there opens a plain empty
line above and puts the caret on it, leaving the heading itself untouched.
Without the latter there was no way to write ordinary text above a heading —
the text silently became part of it (reported from the field).
Heading style can never spill past its line because
normalizeTypingAttributes derives the caret's typing attributes from the
caret line's own classification, in both directions.
UndoHistory (KMP) stores markdown-resource snapshots: pushed before every
formatting action, every Enter, and debounced 2 s after typing. Undo/redo
restore a snapshot via setMarkdownSource (full rebuild).
Each of these shipped as a red TDD test in
NoteEditingRegressionTests.swift
and is now fixed (tests green):
1. Enter at the end of a screen-filling note lost the caret (reported as
"scrolls to top"). Three stacked faults, reproduced and verified fixed in
the simulator with the software keyboard up:
(a) intercepted edits suppress the selection-change callback
(isUpdating), so nothing scrolled at all — finishIntercept now calls
scrollToCursorIfNeeded();
(b) scrollRectToVisible treats the area behind the keyboard/toolbar
as visible (inset-blind), so even when called it left the new line under
the keyboard — the scroll target is now computed manually against
adjustedContentInset;
(c) TextKit answers caret-rect queries for a line added this runloop
turn with a null/.zero rect until layout runs — a .zero rect
converted to scroll-view coordinates is the top of the note, which is
the "scrolls to top" symptom. scrollToCursorIfNeeded now forces layout
first and safeCaretRectInTextView rejects degenerate rects (with a
caretRect(for:) primary / firstRect(for:) fallback). The
keyboard-appearance scroll in EditorToolbarController routes through
the same hardened path. (The unit harness cannot reproduce the TextKit
timing — the simulator repro is the verification of record.)
2. Wrapped bullet lines misaligned: headIndent used fixed constants
(20 pt / prefixLen × 8.5). Styles.listParagraph(prefix:indentLevel:)
now measures the rendered prefix width, so the second visual line starts
exactly where the text after the bullet starts.
3. No auto-capitalization on a new bullet line: the grey - prefix
precedes the caret on the display line, so UIKit's .sentences
autocapitalization never fires there.
Coordinator.listStartAutocapitalized uppercases a single lowercase
letter inserted at a list line's content start.
4. Bullet button lit up on the trailing empty line:
bulletModeAtCursor clamped end-of-document to the previous line, so
an empty last line after 1. a\n reported numbered. A caret past a
trailing newline now reports off; mixed ordered/unordered nesting
reports the caret line's own kind.
5. Backspace on a bullet line (caret at content start) now steps the
item back — dedent one level when indented, else remove the whole prefix
(list off) — via Coordinator.performListBackspace; it never leaves
-hello fragments. Relatedly, normalizeListTypingAttributes now
derives the caret's paragraph-level typing attributes from the current
line's classification instead of patching the prefix tag once — so after
a dedent or prefix removal the caret (and the next typed run) drops the
old level's indent instead of rendering phantom bullet indentation that
no amount of dedenting could remove.
6. Ordinals reset after an unordered sub-list: toggleListItem now
continues ordered numbering from the previous same-indent ordered sibling
(scanning past deeper-nested sub-lists), and the bullets button only
switches between ordered/unordered once a list is active — removal is
backspace/dedent's job.
7. Note duplicated below the cursor after an edit (field report: select
lines → backspace → cursor mid-note, everything duplicated below it).
Root cause: setBodyText (the path an external body update takes — a
sync landing while the note is open) rebuilt the display without
refreshing lastReconciledDisplay; the next UIKit-originated
textViewDidChange then diffed the new display against the old one
and spliced the whole difference back into the resource. Three fixes:
setBodyText now refreshes the baseline; display deletions map with a
deletion-biased range (sourceNSRangeForDeletion) so a selection
starting at a display line start consumes the line's hidden indent
instead of orphaning it; and verifyConsistency's length-check +
self-heal now runs in release builds too, so any residual desync
rebuilds from resource instead of compounding across edits.
8. Indent/dedent on normal text now indents by a full list level and
wraps flush. Markdown representation: keep the leading spaces (2 per
level) in the resource — this vault's grammar has no indented-code-block
rule so ≥4 spaces is safe internally (note: strict CommonMark renderers
would treat 4+ leading spaces as code; if external interop ever matters,
revisit). Styles.indentedParagraph aligns text indented N levels with
the text of a bullet indented N times — " text" starts where
" - item"'s "item" starts (~30pt), a full bullet-level step per
indent. (Two smaller anchors were rejected in the field as "barely
indented": the drawn width of two spaces ~9pt, and a top-level bullet's
~10pt text column.) The leading spaces stay visible (unlike a list
line's hidden
indent) so the caret can walk them and dedent operates on real
characters; the first line's indent is therefore the target column minus
their drawn width, and headIndent is the target so wrapped lines stay
flush.
9. Typing at the start of a block's text escaped the block (found while
adding headings; blockquotes had it latent since they shipped). At a
mid-document line start the insertion mapping stopped at the end of the
preceding newline segment and returned the source offset before the
line's hidden block prefix, so typing at the display start of
prev\n> quoted produced prev\nX> quoted — the quote (or heading) broke.
At the document start the same caret mapped correctly, which is why it
went unnoticed. OffsetMap.Segment now carries isBlockPrefix (set by
ParagraphBuilder for leading indent, > and # — deliberately NOT for
inline markers, where typing at a line start still means plain text before
the span), and sourceOffsetForInsertion skips past such a run at a line
boundary. performHeaderBackspace depends on the corrected mapping to
recognize "caret at the heading's content start" at all.
10. **Ordered lists didn't renumber when an item left via deletion or a
heading conversion.** Renumbering only ran from the deliberate list
transforms; the deletion routes bypassed it and renumberOrderedRun
treated a heading as prose (so a run flowed through it). Now
deleteRange renumbers newline-spanning deletions (backspacing an item,
or joining a blank away), performListBackspace routes prefix removal
through the shared FormattingEngine.removeListPrefix (which renumbers)
instead of a raw splice, and a heading ends an ordered run — promoting an
item to a heading restarts the list below it at 1, converting back
rejoins the runs.
11. Grey list prefixes were styleable and the caret could rest in one.
Selecting through a - and hitting bold produced - item, which
stopped classifying as a list. FormattingEngine.toggleFlag now clamps
styling to each line's content start (the prefix is never wrapped), and
Coordinator.selectionSnappedOutOfListPrefix snaps a bare caret out of
the prefix run (ranges are left intact so copy keeps the - ). This also
closed the latent case of typing at a bullet's display line start, which
inserted ahead of the marker in resource.