Skip to content

Tags: tqbf/mdv

Tags

v1.5.1

Toggle v1.5.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.5.0

Toggle v1.5.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.4.2

Toggle v1.4.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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%

v1.4.1

Toggle v1.4.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.4.0

Toggle v1.4.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.3.2

Toggle v1.3.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.3.1

Toggle v1.3.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #22 from jvanderberg/feat/zoom

View → Zoom (⌘=/⌘−), zoom HUD, native checkmarks in theme menu

vv1.1.0

Toggle vv1.1.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #12 from jvanderberg/main

Add CLI installer, in-app help, and System-appearance theme

v1.3.0

Toggle v1.3.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
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>

v1.2.2

Toggle v1.2.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Merge pull request #16 from tqbf/tqbf/solarium-besley

Rename Solarized → Solarium Daylight/Moonlight (Besley slab serif)