Tags: tqbf/mdv
Tags
Thematic-break smart typography (#30) + O(n²) DoS fix (#32) * Fix thematic-break rendering when Smart Typography is enabled `smartenMarkdown` processed source character-by-character and converted every `---` run to an em-dash before MarkdownUI ever saw the text. A standalone `----` block therefore arrived at the renderer as `—-` (unrecognised) instead of a thematic break, and was rendered as plain text. The same corruption broke setext H2 underlines (`heading\n----`). Fix: skip smartening for any line whose characters are entirely one of `-`, `*`, `_` with optional spaces/tabs and at least three marker chars — the exact CommonMark thematic-break grammar. Two guard layers: * Early-exit at block scope (`looksLikeThematicBreakLine(trimmed)`) for the common standalone-rule case. * Line-level look-ahead inside the character loop (`atLineStart` flag) so thematic-break lines inside multi-line blocks (setext headings) are also preserved verbatim. Follows the same pattern already used for fenced-code-block and GFM-table early-exits added in #23. Also adds `test-docs/thematic-break.md` — a small document that exercises every CommonMark thematic-break variant (`---`, `----`, `* * *`, `_ _ _`, `- - -`, setext H2, and inline dash sequences that must *not* become rules) with and without Smart Typography. * Document thematic-break.md in test-docs/README.md * SmartTypography: derive line-start from output, fixing O(n^2) hang The thematic-break look-ahead added in the parent commits gated on a hand-tracked `atLineStart` flag that was cleared in only 3 of the ~9 early-`continue` branches. The em-dash branch advanced `i` by 3 within a `-` run but never cleared the flag, so the look-ahead re-fired at every step and re-scanned (and re-allocated via `String(...)`) the shrinking tail to end-of-line: O(n^2) time and allocation on a single long `-` line. A crafted file (tens of thousands of `-` then any non-marker char, which slips past the cheap whole-line guard) hangs the viewer on the main thread — measured 3.4s at 32 KB, quadratic, ~14 min extrapolated at 512 KB. Derive `atLineStart` each iteration from the emitted output (`result.last == "\n"`, O(1)) instead of a sticky flag: correct no matter which branch consumed the line, so the look-ahead fires at most once per line. Pass the look-ahead slice as a Substring instead of allocating a String per call. Restores linear time (512 KB of `-`: 0.06s) and additionally fixes two latent mis-fires the stale flag caused (`<br>---` and `...---` mid-line no longer suppress em-dash smartening). The PR #30 test plan still passes; this commit only changes the line-start bookkeeping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hazadus <hazadus7@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Feat/mermaid rendering (#29) * Render Mermaid diagrams natively * Tighten Mermaid diagram sizing and chrome polish Builds on Darvell's mermaid-rendering work. Three small fixes: - Drop the fixed `diagramHeight()` formula and `minHeight: proxy.size.height` on the unzoomed branch. The image now flows at container-width × intrinsic aspect, eliminating the 60-150pt of dead space above and below each diagram. - Skip the inner `ScrollView` when zoom is 1. Previously the wrapping ScrollView consumed wheel events even at the natural fit, so the document scroll would stall whenever the cursor passed over a diagram. The zoom > 1 branch still wraps in a ScrollView (with a pinned 540pt viewport) so panning works. - Style picker: replace per-option `Toggle`+manual binding with a `Picker`, giving native radio semantics and a proper checkmark on the selected style. Also swaps `foregroundColor` for `foregroundStyle` on the chrome icons. Co-Authored-By: Darvell <1046915+darvell@users.noreply.github.com> * Mermaid: floating diagram chrome + source view matches code-block look Two follow-ups on top of the sizing fix: - Diagram view: drop the stacked-above chrome row and float the toolbar as a translucent capsule in the top-right of the diagram (Preview / Quick Look style). The diagram now occupies the full box. The capsule is hover- revealed, .thinMaterial background with a hairline border. - Source view: when toggled to source, render with mdv's normal code-block chrome — `mermaid` language label top-left, hover-revealed wrap / show-diagram / copy buttons top-right, horizontal-scroll or wrap layout for the code. The view now feels like every other fenced block in the document instead of a one-off. Context menu also splits by mode: source shows wrap; diagram shows style picker and export. Co-Authored-By: Darvell <1046915+darvell@users.noreply.github.com> * Mermaid: diagram style is global and persists across launches `@State` was wrong — it scoped the style to a single diagram instance, so each block had its own palette and the picker felt useless. Move to `@AppStorage("mdv.mermaid.style")` so picking a style updates every diagram in the document at once and the choice survives restarts. Co-Authored-By: Darvell <1046915+darvell@users.noreply.github.com> --------- Co-authored-by: Darvell <1046915+darvell@users.noreply.github.com> Co-authored-by: Agent Zero <agentzero@ELSTON.local>
Parse markdown once per document load, not per scroll frame (#26) `blocks` and `tocHeadings` were computed properties on `ContentView` that re-walked the raw markdown string on every access — splitting it into lines, fence-grouping, trimming, etc. The markdown body amplified this: each row in the `LazyVStack(ForEach(blocks))` applies `.modifier(BlockTextSelection(isHeading: isHeadingBlock(idx)))`, and `isHeadingBlock` calls `tocHeadings`, whose getter iterates `blocks` — invoking the getter again. A single body re-evaluation during scroll therefore did roughly 2N + 1 full document re-parses for a doc of N blocks. The result: 100% main thread, `GraphHost.flushTransactions` saturated, visible scroll jitter on documents of more than a few KB. Sampling with `sample <pid>` showed `ContentView.blocks.getter` / `ContentView.tocHeadings.getter` consuming the entire main thread, buried inside `Sequence.starts(with:)`, `components(separatedBy:)`, and `joined(separator:)`. Fix: introduce a `ParsedDocument` value type that holds `raw`, `blocks`, and `tocHeadings`, parsed once in `init(raw:)`. `ContentView` now stores `@State document: ParsedDocument` as the source of truth and exposes `rawMarkdown` as a thin getter/setter over `document.raw`, so every existing read/write site is unchanged. `blocks` and `tocHeadings` become O(1) reads of cached fields. Before / after, scrolling the same document for 5s: Stack frames touching blocks.getter / tocHeadings.getter: 234 -> 2 GraphHost.flushTransactions share of main thread: ~38% -> ~1.6% Main thread idle in mach_msg2_trap: 0% -> ~50%
Heading-click copies the section; restore normal text selection (#25) Block-level drag-select turned out to be annoying in practice when the user actually wanted to grab a phrase. Revert the LazyVStack to `.textSelection(.enabled)` (drag-select + ⌘C work the standard macOS way) and trade the block-select machinery for a single-action affordance on headings only: a click on any ATX heading copies that heading's section (heading + content down to the next same-or-higher heading) as raw markdown source to the pasteboard, then flashes the section briefly in accent tint as visual confirmation. Headings get `.textSelection(.disabled)` so the `.onTapGesture` actually fires — a parent `.textSelection(.enabled)` otherwise wins clicks and routes them to text-selection (extend/clear) instead. Prose blocks keep text selection on. Heading hover changes the cursor to a pointing hand, the standard "this is clickable" cue. Removes the block-select drag gesture, `BlockFramesKey` PreferenceKey, `SelectionEscapeMonitor`, the custom Edit > Cut/Copy/Paste/Select-All command group, the Esc-to-clear plumbing, and the responder-chain delegation helper. Back to the system pasteboard menu group; ⌘C / ⌘A work however the focused view wants them to. Co-authored-by: Agent Zero <agentzero@ELSTON.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Block-level selection + copy as markdown (#24) * Block-level selection + copy as markdown Replace `.textSelection(.enabled)` with a drag-select model that operates on whole blocks. Drag with the mouse highlights blocks the cursor passes over; if any heading falls in the dragged range, that heading's section is auto-expanded (down to the next same-or-higher-level heading) so crossing a heading "feels" like grabbing the section. Double-click on a heading selects its section. ⌘A selects every block. ⌘C copies the selected blocks' source markdown joined with `\n\n`. The Edit > Cut/Copy/Paste/Select-All command group is replaced so ⌘C / ⌘A route through us; when a text field (find bar, sidebar search) is focused, we delegate to the responder chain via NSApp.sendAction so field-local cut/copy/paste/select-all still work. Per-block frames are tracked via a `BlockFramesKey` PreferenceKey on a named coordinate space; the drag gesture maps cursor y → block index. Drag is attached as `.simultaneousGesture` so per-block taps still fire (a `.gesture(DragGesture)` parent suppresses descendant taps even with minimumDistance > 0). Selection auto-clears on rawMarkdown change so indices don't leak across file swaps. NotificationHandlers split into two ViewModifiers; the chain blew the SwiftUI type-checker budget after the new copy/select-all entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Esc clears block selection when not in find / not in text field New `SelectionEscapeMonitor` is permanently installed (paired with paneTracker's lifecycle) and gates on selection-non-empty AND not-searching AND no-field-editor-focused, so it doesn't steal Esc from the find bar (`KeyMonitor` already handles that) or from any focused text field. Closures re-read live state on each fire rather than capturing it at install time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Agent Zero <agentzero@ELSTON.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Skip smartening for GFM table blocks (#23) Smart typography rewrote `---` → `—` (em-dash), which corrupted GFM table separator rows like `|---|---|---|---|` into `|—|—|—|—|`. cmark-gfm then failed to recognize the block as a table and rendered it as raw text with visible `|` separators. Detect table blocks via the separator row (line whose chars are entirely `|`, `-`, `:`, whitespace, with at least one `-` and two `|`) and return source unchanged. Cells lose curly punctuation but render *as a table* — bigger win. Co-authored-by: Agent Zero <agentzero@ELSTON.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Block remote images by default; View → Load Remote Images toggle (#20) * Block remote images by default; View → Load Remote Images toggle `LocalImageProvider` now refuses http(s) fetches when mdv_load_remote_images (@AppStorage, default false) is off, rendering a clickable "Remote image blocked" placeholder instead. Click pops the View menu open at the cursor with "Load Remote Images" pre-highlighted via NSMenu.popUp(positioning:at:in:) — gives the user a one-click path from "I see a blocked image" to the persistent preference toggle. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Replace MarkdownUI default fetcher with RemoteImageView Default ImageProvider silently rendered nothing on 404 / DNS failure / non-image response — confusing right after the user enables Load Remote Images. RemoteImageView fetches with URLSession, caches via NSCache<NSString, NSImage>, and surfaces three explicit states: loading, loaded (sized to intrinsic), failed (orange-tinted placeholder with host + reason). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Agent Zero <agentzero@ELSTON.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PreviousNext