Convert from git submodule to plain vendored copy. No push access to upstream nodes-app/swift-markdown-engine meant our local fix commit (05c1720) was stranded and unreachable from any remote on fresh clones/CI. Vendoring as plain files folds it into normal repo history instead.
378 lines
21 KiB
Markdown
378 lines
21 KiB
Markdown
# Changelog
|
||
|
||
All notable changes to swift-markdown-engine are documented in this file.
|
||
|
||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||
|
||
## [Unreleased]
|
||
|
||
## [0.12.0] - 2026-08-10
|
||
|
||
### Added
|
||
- `onPersistScrollOffset` / `restoreScrollOffset` on `NativeTextViewWrapper` —
|
||
scroll memory an embedder can keep somewhere that outlives the editor. The
|
||
engine's own per-document offsets live on the coordinator, so an embedder that
|
||
routes to a different screen and back lost them: nothing recorded the offset on
|
||
the way out (there was no `dismantleNSView` at all), and the restore was gated
|
||
on a document switch, which a remount is not — `makeCoordinator` seeds
|
||
`documentId`, so the first update pass never looks like one. Teardown now hands
|
||
the offset over, and the restore is latched instead of gated, retrying for a
|
||
bounded few passes because the first pass after a remount still carries the
|
||
embedder's empty buffer. Both closures are asked at call time, so the
|
||
embedder's own retention rules can see changes made on the way out. Passing
|
||
neither leaves behavior unchanged.
|
||
|
||
- `NSAttributedString.Key.markdownBlockBackground` — a background painted
|
||
across the whole line box by `MarkdownTextLayoutFragment` instead of the
|
||
glyph box AppKit's `.backgroundColor` covers. Embedder extensions can use it
|
||
wherever a fill should read as a block.
|
||
|
||
### Changed
|
||
- **Inline parse cost is linear in the spans per region, not quadratic.** Every
|
||
pass after the first consulted the claimed ranges by scanning the whole array
|
||
— once per character in `scanEscapes` and `collectDelimiterRuns`, once per
|
||
candidate in `scanLinkFamily` — and `buildTree` decided containment by testing
|
||
each span against every other. The passes walk the string left to right and
|
||
claimed ranges never partially overlap, so a cursor over the sorted ranges answers both
|
||
questions in amortised constant time, and sorting spans by start ascending /
|
||
length descending turns containment into a single ordered walk. A paragraph of
|
||
240 code spans parses in 0.5ms rather than 33ms; 6x the spans now costs 6x the
|
||
parse instead of ~30x. Affects every claimed-span construct — code, escapes,
|
||
links, images, wiki links, inline LaTeX, emphasis, and extension spans. No
|
||
parse result changes.
|
||
- `==highlight==` fills the line box. AppKit paints `.backgroundColor` over
|
||
ascent + descent only, so the marker fell short of the line height by the
|
||
leading plus `paragraph.lineHeightExtraSpacing`, and a highlight that wrapped
|
||
came out as a stack of bands. `HighlightExtension` returns
|
||
`.markdownBlockBackground` now, so the block is continuous at any font size.
|
||
Table cells rasterize their own text and keep the glyph-box fill.
|
||
|
||
### Fixed
|
||
- Bare URLs and emails survive rich copy as real links. The editor styler
|
||
linkifies them with `NSDataDetector`, but the HTML renderer emitted them as
|
||
plain text, so the pasteboard's HTML/RTF/web-archive flavors carried no anchor
|
||
at all, and whether a copied URL arrived clickable was left to the receiving
|
||
app — Apple Mail runs its own detection and linkifies anyway, a consumer that
|
||
takes the rich flavor verbatim pastes dead text. `MarkdownHTMLRenderer` now
|
||
wraps detector matches in `<a href>` (emails as `mailto:`) using the same
|
||
system detector as the styler; the RTF and web-archive flavors are derived
|
||
from that HTML, so all three inherit the link. Explicit `[title](url)` links
|
||
were already correct; a URL-shaped run inside a link's own title stays plain
|
||
so anchors never nest, and code spans remain excluded, matching the styler.
|
||
Table cells are unaffected: they render no inline markup on the copy path.
|
||
- Markdown link labels may hold inline code and escaped punctuation —
|
||
``[`App`](/tmp/App.swift:56)`` stayed literal. Code spans and escapes are
|
||
claimed before links so they stay opaque, and the link pass rejected every
|
||
candidate overlapping a claimed span, including one lying entirely inside the
|
||
label. Spans contained in the label are permitted now, links act as
|
||
containers when the tree is built, and partial overlaps or spans crossing the
|
||
label boundary are still rejected.
|
||
- Initially narrow tables reflow when the editor width shrinks instead of
|
||
retaining stale image geometry until an unrelated full restyle.
|
||
|
||
## [0.11.0] - 2026-07-31
|
||
|
||
### Added
|
||
- **Ordered lists render their position.** An item's number is computed from its
|
||
place in the run and painted over the source digits, so typing, deleting,
|
||
merging and pasting renumber live. The `.md` file is never rewritten — the
|
||
source stays valid CommonMark whatever it says. The whole source marker is
|
||
hidden as one unit and the slot is kerned to the display width, so the dot
|
||
travels with the digits at any digit count; the raw digits are revealed while
|
||
they are edited.
|
||
- `MarkdownEditorConfiguration.cursorFollowsSpanInk` (opt-in, off by default):
|
||
the caret and the I-beam take the ink of the extension span they sit in. It
|
||
matters for an extension that INVERTS its content — dark ink on a light block
|
||
— where both cursors are otherwise drawn in the block's own color and
|
||
disappear inside it. `InvertedIBeamCursor` recolors the live `NSCursor.iBeam`
|
||
image, which keeps the system shape and the user's pointer size.
|
||
|
||
### Fixed
|
||
- Find-in-document no longer erases other backgrounds. Clearing its highlights
|
||
removed `.backgroundColor` across the whole document, which blanked extension
|
||
spans, code fences and table cells until some unrelated restyle repainted
|
||
them. Find now marks its own backgrounds and restyles only the paragraphs it
|
||
touched.
|
||
|
||
### Performance
|
||
- **Large notes open ~14× faster.** Measured on a 346k-char / 5,241-block note
|
||
(Release): first open 19.5 s → 1.35 s, warm open 1.1 s → 590 ms, switching
|
||
away 440 ms → 115 ms. Styling is built on a detached string and transferred
|
||
with one `setAttributedString` — per-key `addAttribute` on live TextKit-2
|
||
storage left weak tombstones in Foundation's attribute-intern table, which
|
||
turned quadratic. The restyle apply uses a paragraph overlap index above 32
|
||
paragraphs, the redundant second full-document parse and the re-entrant
|
||
full-document layout during rebuild are gone, and the SwiftMath render cache
|
||
persists to disk (717 ms → 35 ms on relaunch, byte-identical geometry).
|
||
- **Editing long ordered lists.** One Return in an 800-item loose list:
|
||
944 ms → 67 ms (was quadratic in list length). Typing in a 1,600-item ordered
|
||
list: 89 ms → 41 ms per key. Resolving the caret ink across 1,600 spans:
|
||
0.134 ms → 0.002 ms per keystroke.
|
||
|
||
### Known limitations
|
||
- A list item's continuation line is a paragraph and ends the run, so a
|
||
multi-line item switches numbering off below it.
|
||
- A loose list keeps stale numbers after a pure digit edit (a digit edit is not
|
||
classified as structural).
|
||
- Ordered task items (`1. [ ] x`) consume a number but render none.
|
||
|
||
## [0.10.1] - 2026-07-22
|
||
|
||
### Added
|
||
- Custom SF Symbols for task checkboxes: `MarkdownEditorConfiguration` accepts
|
||
custom unchecked and checked symbols for `- [ ]` / `- [x]` task-list items
|
||
(opt-in; the defaults are unchanged).
|
||
|
||
### Fixed
|
||
- List markers no longer disappear while a selection covers them, and selecting
|
||
a list item now reveals its raw marker syntax like other inline constructs.
|
||
- An unclosed ``` fence no longer swallows the rest of the document: typing an
|
||
opening fence above existing content left every block below it (tables,
|
||
block LaTeX, thematic breaks, links) unrendered until the closing fence was
|
||
typed. A fence now forms a code block only once its closing fence exists.
|
||
|
||
## [0.10.0] - 2026-07-15
|
||
|
||
### Added
|
||
- **Extension seam**: opt-in constructs beyond pure markdown. A
|
||
`MarkdownExtension` contributes an inline form (`==highlight==`), a fenced
|
||
block form (`::: … :::`), or both — plus content attributes and an HTML
|
||
wrapper for the clean-copy path; register instances via
|
||
`MarkdownEditorConfiguration.extensions`. Extensions never emit ranges — the
|
||
parser derives all geometry, so a misbehaving extension can at worst restyle
|
||
its own construct. Marker/fence hiding, caret reveal, incremental restyle,
|
||
table cells, and rich copy are handled generically. Registered extensions can
|
||
change at runtime; all parse caches key on the registry.
|
||
- `HighlightExtension` (`==text==`) and `StrikethroughExtension` (`~~text~~`),
|
||
the former built-ins repackaged as extensions, and `ContainerExtension`
|
||
(`::: … :::`), the first fenced block extension.
|
||
|
||
### Changed
|
||
- **Breaking**: `==highlight==` and `~~strikethrough~~` are no longer part of
|
||
the core grammar. Unregistered, the syntax stays literal text. To keep the
|
||
previous behavior:
|
||
`configuration.extensions = [HighlightExtension(), StrikethroughExtension()]`.
|
||
The formatting actions (context menu, `applyHighlightRequest` /
|
||
`applyStrikethroughRequest`) still insert/remove the markers either way;
|
||
construct detection (toggle-off, selection state) requires the extension.
|
||
|
||
### Fixed
|
||
- A pre-existing incremental-parse gap surfaced by the seam review:
|
||
backspace-joining two paragraphs could leave transiently wrong styling
|
||
(extra spacing or a stray emphasis/code span across the join) until the next
|
||
edit re-parsed the region.
|
||
|
||
## [0.9.0] - 2026-07-13
|
||
|
||
### Added
|
||
- `MarkdownEditorConfiguration.rawSourceMode`: present the document as raw
|
||
Markdown source — no syntax hiding, no markdown styling, and no wiki-link
|
||
display transform (`[[Name|UUID]]` shows verbatim). The editor keeps base
|
||
font/paragraph styling and stays fully editable; smart Markdown input
|
||
handling (list continuation, `$$`/`![[` auto-wrap, ⇧⇥ outdent) is disabled
|
||
while raw. Runtime switching is supported and rebuilds the document
|
||
immediately; the current document's undo stack is dropped on a switch
|
||
because undo actions recorded against the other mode's display text would
|
||
replay at stale ranges. Default `false` — existing embedders are unaffected.
|
||
- Find & replace: two optional bus notifications, `replaceCurrent` (replace the
|
||
focused match and advance) and `replaceAll` (replace every match in one undo
|
||
step, back-to-front so ranges stay valid). Both edit the engine's displayed
|
||
text with proper `shouldChangeText`/`didChangeText` undo registration and
|
||
report the remaining count via `findResults`. Purely additive — embedders that
|
||
don't set the bus names are unaffected.
|
||
- Clean clipboard: ⌘C copies the selection as rich text (RTF + `com.apple.webarchive`)
|
||
built from the AST rather than the raw storage form, and paste converts HTML to
|
||
Markdown. Wiki-link `[[Name|UUID]]` side-channels no longer leak into copied text.
|
||
|
||
### Fixed
|
||
- Find/jump scroll now works without a reading column. The TextKit 2
|
||
fragment-enumeration scroll path (with `.ensuresLayout`) runs universally
|
||
instead of only when `readingWidth` was set; the unreliable
|
||
`NSTextView.scrollRangeToVisible` (which routes through the absent TextKit 1
|
||
layout manager for off-screen content) is now only the last-resort fallback.
|
||
- Inline syntax markers (`**`, `*`, `~~`, `==`) now use `mutedText` foreground
|
||
color while the caret is inside the corresponding span, matching the existing
|
||
behavior of inline code backticks and link/wiki-link brackets. This makes
|
||
highlight `==` markers visually distinct from body text in edit state.
|
||
- Web links `[text](url)` now share the wiki-link "edit zone": clicking the outer
|
||
~30% of the link's first/last visible character places the caret just outside the
|
||
markers (before `[` / after `)`) and reveals the source for editing instead of
|
||
navigating, matching `[[…]]` behavior. Previously the edit zone only resolved
|
||
`.wikiLink` tokens, so a web link dropped the caret between its brackets and did
|
||
not reveal. Middle-of-link clicks still navigate; read-only links stay navigable.
|
||
- Auto-linking (`NSDataDetector`) no longer linkifies a URL that sits inside a
|
||
markdown or wiki link's own range. A link's `(url)` previously got its own
|
||
competing `.link` attribute on top of the link — making the raw URL independently
|
||
navigable and offsetting the click edit zone. Bare URLs outside links still
|
||
autolink; URLs inside code were already excluded.
|
||
- Wiki links: UUID-robust labels/embeds and keyboard navigation in the inline
|
||
autocomplete list.
|
||
|
||
### Contributors
|
||
- Find/jump scroll fix and find & replace by @ChristineTham
|
||
- Inline syntax-marker color fix by @sospartan
|
||
- rawSourceMode, clean clipboard, web-link edit zone, and wiki-link robustness by @luca-chen198
|
||
|
||
## [0.8.0] - 2026-06-28
|
||
|
||
### Added
|
||
- `MarkdownEditorBus.findQuery` / `findResults`: query-based in-document find. The host posts a
|
||
search string (+ current index) and the engine matches against its OWN displayed text,
|
||
highlighting in display coordinates and posting the match count back. This is correct where the
|
||
displayed text differs from the source — e.g. node links rendered shorter than `[[Name|UUID]]`,
|
||
LaTeX, or images — which the legacy `findScrollToRange` (host-computed source-coordinate ranges)
|
||
highlighted at the wrong offset. Opt-in; `findScrollToRange` is unchanged for existing embedders.
|
||
- `NativeTextView.isCursorExcluded: ((CGPoint) -> Bool)?` — embedder-supplied
|
||
predicate that suppresses the edit-mode I-beam cursor when the mouse is inside
|
||
a defined exclusion zone (e.g. a formatting toolbar). When the closure returns
|
||
`true`, `mouseMoved:` skips calling `super.mouseMoved` to avoid NSTextView's
|
||
built-in I-beam cursor, setting the arrow cursor instead. Exposed through
|
||
`NativeTextViewWrapper.isCursorExcluded`.
|
||
- `NativeTextViewWrapper.onBuildContextMenu: ((NSMenu, NSRange) -> NSMenu)?` —
|
||
embedder hook to build the editor's right-click menu. The engine hands over the
|
||
default `NSMenu` + the current selection; the embedder returns the menu to show
|
||
(driving the `didMarkdown*` actions through the bus). Keeps the engine UI-free.
|
||
- `==highlight==` inline markup: double-equals markers around text apply a
|
||
background color (configurable via `MarkdownEditorTheme.highlightColor`,
|
||
default `.systemOrange.withAlphaComponent(0.4)`). Content is recursively parsed so nested emphasis,
|
||
code, etc. work inside highlights.
|
||
- `MarkdownEditorBus.applyHighlightRequest` /
|
||
`selectionHighlightDidChange`: bus notification names for driving a
|
||
highlight toolbar button from host UI.
|
||
- `MarkdownEditorBus` extended with nine new notification types for
|
||
formatting toolbar integration: `applyStrikethroughRequest`,
|
||
`applyInlineCodeRequest`, `applyBlockquoteRequest`,
|
||
`applyUnorderedListRequest`, `applyOrderedListRequest`,
|
||
`applyLinkRequest`, `applyCodeBlockRequest`,
|
||
`applyHorizontalRuleRequest`, `applyImageRequest`. Embedders wire
|
||
these into `NotificationCenter` to trigger formatting from
|
||
external UI (toolbars, menus) without reaching into the editor's
|
||
view hierarchy.
|
||
- New formatting actions on the coordinator (callable directly or
|
||
via the bus above): `didMarkdownStrikethrough`, `didMarkdownInlineCode`,
|
||
`didMarkdownBlockquote`, `didMarkdownLink`, `didMarkdownCodeBlock`,
|
||
`didMarkdownHorizontalRule`, `didMarkdownImage`.
|
||
- Word-boundary detection in inline formatting: when the cursor is
|
||
placed inside an English word with no active text selection, bold,
|
||
italic, strikethrough, and inline-code actions now auto-select the
|
||
containing word before wrapping. If no word character is adjacent
|
||
to the cursor, empty markers are inserted as before. The cursor's
|
||
relative offset within the word is preserved after wrapping
|
||
(e.g. `wo|rd` → `**wo|rd**`).
|
||
- Headless test suite for formatting actions (`FormattingActionTests`
|
||
— 21 tests covering bold, strikethrough, inline code, blockquote,
|
||
link, code block, horizontal rule, and image insertion).
|
||
|
||
### Changed
|
||
- The engine no longer ships a built-in right-click "Format" context menu — menus
|
||
are now embedder-supplied via `onBuildContextMenu` (above). The system rich-text
|
||
"Font" submenu (Bold/Italic/Show Colors…) is stripped from the default menu, since
|
||
those font traits don't apply to Markdown.
|
||
|
||
### Fixed
|
||
- Blockquote removal no longer doubles trailing newlines when the
|
||
original line already carries one.
|
||
|
||
## [0.7.1] - 2026-06-20
|
||
|
||
### Added
|
||
- `MarkdownEditorConfiguration.heightBehavior` (`.scrolls` default / `.fitsContent`):
|
||
in `.fitsContent` the editor grows to its content height and reports it to
|
||
SwiftUI, so an enclosing `ScrollView` scrolls the page instead of a nested
|
||
internal scroller. Opt-in, off by default — no change for existing embedders. (#75)
|
||
- `BlockquoteStyle` configuration struct with `extraLineHeight` to control line
|
||
spacing inside blockquotes, following the `ListStyle.extraLineHeight` /
|
||
`ParagraphStyle.lineHeightExtraSpacing` pattern. Defaults to `0` (no extra
|
||
spacing), preserving existing rendering. (#76)
|
||
|
||
### Fixed
|
||
- Mouse-wheel / trackball scrolling no longer clamps back at the bottom past a
|
||
stale-small content-height measurement. (#71)
|
||
- Inspector clip mask and caret reveal at the document end. (#73)
|
||
- Scroll position is remembered per document across switches, and Writing Tools
|
||
results stay styled and visible after accept. (#70)
|
||
- Empty-file placeholder no longer clips to one line after a view rebuild. (#69)
|
||
|
||
### Added
|
||
- Scroll-away header: `NativeTextViewWrapper` gains `header: AnyView?`,
|
||
`headerCollapsedHeight: CGFloat`, and `headerExpanded: Bool`. The engine
|
||
hosts the supplied SwiftUI view above the document body, scrolling with
|
||
it; collapsing animates the reserved band down to `headerCollapsedHeight`
|
||
(the top row stays visible, lower rows clip away). The hosted content
|
||
refreshes on every SwiftUI update and stays fully interactive. Composes
|
||
with `readingWidth`. See the README's *Scrolling Header* section.
|
||
|
||
### Changed
|
||
- The scroll view's `documentView` is now always an engine-internal
|
||
container view (hosting the text view, the optional scroll-away header,
|
||
and the reading column's breakout overlays) rather than sometimes the
|
||
`NSTextView` itself. Embedders that reached into
|
||
`scrollView.documentView` expecting an `NSTextView` must adapt — the
|
||
document view's class was never API.
|
||
- **Breaking**: The editor's enclosing scroll view no longer applies a
|
||
hard-coded `top: 55.4` content inset. The default is now `0` on every
|
||
edge, matching the most common embedding case where the editor fills
|
||
its container exactly. Embedders that previously relied on the engine
|
||
reserving header space (e.g. for a translucent toolbar) must opt in
|
||
explicitly:
|
||
|
||
```swift
|
||
var config = MarkdownEditorConfiguration.default
|
||
config.safeAreaInsets = SafeAreaInsets(top: 55.4)
|
||
```
|
||
|
||
### Added
|
||
- `SafeAreaInsets` struct exposing `top` / `leading` / `trailing` / `bottom`
|
||
inset knobs for the editor's enclosing scroll view, configurable via
|
||
`MarkdownEditorConfiguration.safeAreaInsets`.
|
||
- `MarkdownASTStyler` now stamps `.spellingState: 0` on fenced code blocks
|
||
and inline `` `code` `` spans, completing the engine's existing
|
||
spell-check suppression convention (links, wiki-links, LaTeX, and tables
|
||
already carry the same attribute). The system spell-checker no longer
|
||
underlines tokens inside code regions even when continuous spell
|
||
checking is enabled.
|
||
|
||
### Fixed
|
||
- Undo is now kept per `documentId`, so Cmd+Z keeps working after switching
|
||
files. The single reused `NSTextView` previously wiped its undo manager on
|
||
every document switch; the editor now vends a per-document `UndoManager`
|
||
(via the new `undoManager(for:)` delegate method) whose undo/redo stack
|
||
survives switching away and back. (#77)
|
||
- A document's surviving undo stack is dropped when its text is reloaded
|
||
*changed* while it was switched away (e.g. renaming a node rewrites the
|
||
`[[label]]` in every file that links it), so Cmd+Z can no longer replay
|
||
stale ranges against the rewritten content. (#78)
|
||
- `NativeTextViewWrapper` keeps links clickable and text selectable
|
||
when `isEditable: false`; `isSelectable` is no longer coupled to
|
||
`isEditable`. (#31)
|
||
- `NativeTextViewWrapper` now applies its initial styling pass even when
|
||
the bound text starts at its final value (e.g. supplied as a SwiftUI
|
||
`@State` initializer). Previously the editor would render the raw
|
||
Markdown source until the user clicked into the document, because the
|
||
coordinator's `lastSyncedText` already matched the bound text at first
|
||
`updateNSView`. The early-return now also requires `didInitialFormatting`
|
||
to be true, which only flips after the first styling pass completes.
|
||
|
||
### Added
|
||
- Initial public API surface:
|
||
- `NativeTextViewWrapper` — SwiftUI bridge for the AppKit-backed editor
|
||
- `MarkdownEditorConfiguration` — every spacing / sizing / behavior knob
|
||
- `MarkdownEditorTheme` — color palette, defaults to system colors
|
||
- `MarkdownEditorServices` — container for the four service protocols
|
||
- Service protocols: `WikiLinkResolver`, `EmbeddedImageProvider`,
|
||
`SyntaxHighlighter`, `LatexRenderer`
|
||
- No-op default implementations: `NoOpWikiLinkResolver`,
|
||
`NoOpEmbeddedImageProvider`, `PlainTextSyntaxHighlighter`,
|
||
`NoOpLatexRenderer`
|
||
- `WikiLinkService` — bidirectional storage / display roundtrip helper
|
||
- `PasteboardImageReader` — pasteboard image inspection helpers
|
||
- Selection / replacement value types: `WikiLinkSelection`,
|
||
`InlineSelectionState`, `InlineReplacementRequest`, `CodeBlockSelection`
|
||
- `CodeBlockButton` — drop-in copy button overlay
|
||
- DocC documentation catalog with landing page and topic groups
|
||
- Triple-slash documentation comments on the full public API surface
|
||
|
||
[Unreleased]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.1...HEAD
|
||
[0.7.1]: https://github.com/nodes-app/swift-markdown-engine/compare/0.7.0...0.7.1
|