From 23a1b7fa50eedf61dc84da622ce5f1131807c7ca Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:29:06 -0600 Subject: [PATCH 1/6] feat: add pure zoom-stepping function Add `nextZoom(from:direction:)` with discrete `zoomSteps` ladder and `ZoomDirection` enum. Pure function; off-ladder inputs snap to nearest rung, result clamped at bounds. Covered by 6 ZoomTests (TDD). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/PreviewController.swift | 22 ++++++++++++++++++ Tests/MarkeeTests/ZoomTests.swift | 32 ++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 Tests/MarkeeTests/ZoomTests.swift diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 6519646..9455667 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -1,6 +1,28 @@ import SwiftUI import WebKit +/// Discrete zoom rungs, browser-style. Zoom commands only ever land the +/// page on one of these values. +let zoomSteps: [Double] = [0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] + +enum ZoomDirection { + case `in`, out +} + +/// Step `current` one rung along `zoomSteps`. Off-ladder inputs snap to the +/// nearest rung first; the result is clamped at the array bounds. +func nextZoom(from current: Double, direction: ZoomDirection) -> Double { + let nearest = zoomSteps.indices.min(by: { + abs(zoomSteps[$0] - current) < abs(zoomSteps[$1] - current) + }) ?? 0 + switch direction { + case .in: + return zoomSteps[min(nearest + 1, zoomSteps.count - 1)] + case .out: + return zoomSteps[max(nearest - 1, 0)] + } +} + struct OutlineEntry: Identifiable, Hashable { let id: String // heading slug / anchor let level: Int // 1..6 diff --git a/Tests/MarkeeTests/ZoomTests.swift b/Tests/MarkeeTests/ZoomTests.swift new file mode 100644 index 0000000..4631cc9 --- /dev/null +++ b/Tests/MarkeeTests/ZoomTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import Markee + +final class ZoomTests: XCTestCase { + func test_zoomIn_steppsUpOneRung() { + XCTAssertEqual(nextZoom(from: 1.0, direction: .in), 1.1, accuracy: 0.0001) + } + + func test_zoomOut_stepsDownOneRung() { + XCTAssertEqual(nextZoom(from: 1.0, direction: .out), 0.9, accuracy: 0.0001) + } + + func test_zoomIn_clampsAtTop() { + XCTAssertEqual(nextZoom(from: 3.0, direction: .in), 3.0, accuracy: 0.0001) + } + + func test_zoomOut_clampsAtBottom() { + XCTAssertEqual(nextZoom(from: 0.5, direction: .out), 0.5, accuracy: 0.0001) + } + + func test_offLadderInput_snapsToNearestRungThenSteps() { + // 1.04 is nearest to the 1.0 rung; stepping in lands on 1.1. + XCTAssertEqual(nextZoom(from: 1.04, direction: .in), 1.1, accuracy: 0.0001) + XCTAssertEqual(nextZoom(from: 1.04, direction: .out), 0.9, accuracy: 0.0001) + } + + func test_steppingUpRepeatedlyReachesTop() { + var level = 0.5 + for _ in 0..<20 { level = nextZoom(from: level, direction: .in) } + XCTAssertEqual(level, 3.0, accuracy: 0.0001) + } +} From 2d13f71b7ade4ae2822f20a6e8615284820e0651 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:30:15 -0600 Subject: [PATCH 2/6] feat: add setZoom to window.markee API Co-Authored-By: Claude Opus 4.7 (1M context) --- Resources/web/app.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Resources/web/app.js b/Resources/web/app.js index 79e3892..c8bf7aa 100644 --- a/Resources/web/app.js +++ b/Resources/web/app.js @@ -329,11 +329,22 @@ ${cssParts.join("\n\n")} return head + clone.outerHTML + foot; } + // ---- zoom --------------------------------------------------------------- + // Swift drives zoom by calling window.markee.setZoom(factor). CSS `zoom` + // on reflows text and scales code blocks, images, KaTeX, and + // Mermaid output uniformly. Invalid/non-positive factors reset to 1. + function setZoom(factor) { + const f = Number(factor); + document.documentElement.style.zoom = + (Number.isFinite(f) && f > 0) ? String(f) : "1"; + } + // ---- expose API --------------------------------------------------------- window.markee = { render, scrollToHeading, - exportStandalone + exportStandalone, + setZoom }; // Mermaid is loaded as a module; the inline initializer (or load failure) From cdcbc8f8d08f9080176cdc6c487f91b4f1d943b5 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:32:19 -0600 Subject: [PATCH 3/6] feat: add zoom in/out/reset keyboard commands Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/MarkeeApp.swift | 18 +++++++++ Sources/Markee/PreviewController.swift | 55 ++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index edc4e89..8185398 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -23,6 +23,20 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .toggleOutline, object: nil) } .keyboardShortcut("\\", modifiers: [.command, .option]) + + Divider() + Button("Zoom In") { + NotificationCenter.default.post(name: .zoomIn, object: nil) + } + .keyboardShortcut("+", modifiers: [.command]) + Button("Zoom Out") { + NotificationCenter.default.post(name: .zoomOut, object: nil) + } + .keyboardShortcut("-", modifiers: [.command]) + Button("Actual Size") { + NotificationCenter.default.post(name: .zoomReset, object: nil) + } + .keyboardShortcut("0", modifiers: [.command]) } CommandGroup(after: .saveItem) { Button("Export Standalone HTML…") { @@ -56,6 +70,10 @@ extension Notification.Name { static let openInEditor = Notification.Name("MarkeeOpenInEditor") static let findInPreview = Notification.Name("MarkeeFindInPreview") static let printPreview = Notification.Name("MarkeePrintPreview") + static let zoomIn = Notification.Name("MarkeeZoomIn") + static let zoomOut = Notification.Name("MarkeeZoomOut") + static let zoomReset = Notification.Name("MarkeeZoomReset") + static let zoomDidChange = Notification.Name("MarkeeZoomDidChange") } final class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 9455667..9f3c970 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -95,6 +95,18 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle NotificationCenter.default.addObserver( self, selector: #selector(handlePrint), name: .printPreview, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleZoomIn), + name: .zoomIn, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleZoomOut), + name: .zoomOut, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleZoomReset), + name: .zoomReset, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleZoomDidChange), + name: .zoomDidChange, object: nil) } deinit { @@ -224,6 +236,48 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle op.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil) } + // MARK: - Zoom + + /// UserDefaults key for the single, global zoom level (shared across all + /// windows and remembered across launches). + private static let zoomDefaultsKey = "MarkeeZoomLevel" + + private static var storedZoom: Double { + get { UserDefaults.standard.object(forKey: zoomDefaultsKey) as? Double ?? 1.0 } + set { UserDefaults.standard.set(newValue, forKey: zoomDefaultsKey) } + } + + @objc private func handleZoomIn() { + changeZoom { nextZoom(from: $0, direction: .in) } + } + + @objc private func handleZoomOut() { + changeZoom { nextZoom(from: $0, direction: .out) } + } + + @objc private func handleZoomReset() { + changeZoom { _ in 1.0 } + } + + /// Compute + persist a new zoom level, then broadcast so every open + /// window re-applies it. Only the key window's controller acts. + private func changeZoom(_ transform: (Double) -> Double) { + guard webView.window?.isKeyWindow == true else { return } + Self.storedZoom = transform(Self.storedZoom) + NotificationCenter.default.post(name: .zoomDidChange, object: nil) + } + + @objc private func handleZoomDidChange() { + applyZoom() + } + + /// Push the stored zoom level into this window's WebView. + private func applyZoom() { + webView.evaluateJavaScript( + "window.markee && window.markee.setZoom(\(Self.storedZoom));", + completionHandler: nil) + } + /// Looks up the source line of the currently-active heading, if any. private func currentHeadingLine() -> Int? { guard let id = currentHeadingID else { return nil } @@ -288,6 +342,7 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle pendingRender = nil render(source: pending) } + applyZoom() case "outline": if let items = body["items"] as? [[String: Any]] { self.outline = items.compactMap { d in From e79e385e27578dc4a1c1e943b6b9b050027e13c8 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:33:51 -0600 Subject: [PATCH 4/6] feat: add Find Next/Previous commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ⌘G (Find Next) and ⌘⇧G (Find Previous) menu items that reuse the existing findNext()/findPrevious() methods; falls back to opening the find bar when no query has been entered yet. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/MarkeeApp.swift | 10 ++++++++++ Sources/Markee/PreviewController.swift | 27 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 8185398..483869e 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -59,6 +59,14 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .findInPreview, object: nil) } .keyboardShortcut("F", modifiers: [.command]) + Button("Find Next") { + NotificationCenter.default.post(name: .findNext, object: nil) + } + .keyboardShortcut("G", modifiers: [.command]) + Button("Find Previous") { + NotificationCenter.default.post(name: .findPrevious, object: nil) + } + .keyboardShortcut("G", modifiers: [.command, .shift]) } } } @@ -74,6 +82,8 @@ extension Notification.Name { static let zoomOut = Notification.Name("MarkeeZoomOut") static let zoomReset = Notification.Name("MarkeeZoomReset") static let zoomDidChange = Notification.Name("MarkeeZoomDidChange") + static let findNext = Notification.Name("MarkeeFindNext") + static let findPrevious = Notification.Name("MarkeeFindPrevious") } final class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 9f3c970..53d52c8 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -107,6 +107,12 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle NotificationCenter.default.addObserver( self, selector: #selector(handleZoomDidChange), name: .zoomDidChange, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleFindNext), + name: .findNext, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleFindPrevious), + name: .findPrevious, object: nil) } deinit { @@ -208,6 +214,27 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle showFindBar = true } + /// ⌘G — if there is no query yet, just reveal the find bar (same as ⌘F); + /// otherwise search forward with the last query, bar visible or not. + @objc private func handleFindNext() { + guard webView.window?.isKeyWindow == true else { return } + if findQuery.isEmpty { + showFindBar = true + } else { + findNext() + } + } + + /// ⌘⇧G — mirror of handleFindNext, searching backward. + @objc private func handleFindPrevious() { + guard webView.window?.isKeyWindow == true else { return } + if findQuery.isEmpty { + showFindBar = true + } else { + findPrevious() + } + } + func findNext() { runFind(backwards: false) } func findPrevious() { runFind(backwards: true) } From 591b2bf98755f3c9d8fc7d2defdfa22331f8e8a9 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:35:14 -0600 Subject: [PATCH 5/6] feat: add Reload command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ⌘R menu item under View that re-reads and re-renders the current file on demand — a manual fallback for saves the file watcher misses. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/MarkeeApp.swift | 7 +++++++ Sources/Markee/PreviewController.swift | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 483869e..42b2bc8 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -37,6 +37,12 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .zoomReset, object: nil) } .keyboardShortcut("0", modifiers: [.command]) + + Divider() + Button("Reload") { + NotificationCenter.default.post(name: .reloadFile, object: nil) + } + .keyboardShortcut("r", modifiers: [.command]) } CommandGroup(after: .saveItem) { Button("Export Standalone HTML…") { @@ -84,6 +90,7 @@ extension Notification.Name { static let zoomDidChange = Notification.Name("MarkeeZoomDidChange") static let findNext = Notification.Name("MarkeeFindNext") static let findPrevious = Notification.Name("MarkeeFindPrevious") + static let reloadFile = Notification.Name("MarkeeReloadFile") } final class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 53d52c8..627b888 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -113,6 +113,9 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle NotificationCenter.default.addObserver( self, selector: #selector(handleFindPrevious), name: .findPrevious, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleReload), + name: .reloadFile, object: nil) } deinit { @@ -225,6 +228,13 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle } } + /// ⌘R — manually re-read and re-render the file. Same path the file + /// watcher drives; a fallback for the rare save the watcher misses. + @objc private func handleReload() { + guard webView.window?.isKeyWindow == true else { return } + loadFromDisk(reason: "manual") + } + /// ⌘⇧G — mirror of handleFindNext, searching backward. @objc private func handleFindPrevious() { guard webView.window?.isKeyWindow == true else { return } From fb675a8166ab9d43e38e6a5b99b9b5a05b00d136 Mon Sep 17 00:00:00 2001 From: Seth Bang <95317728+sethbang@users.noreply.github.com> Date: Mon, 18 May 2026 17:44:20 -0600 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20add=20=E2=8C=98=3D=20zoom-in=20alias?= =?UTF-8?q?;=20clarify=20zoom-sync=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Markee/MarkeeApp.swift | 7 +++++++ Sources/Markee/PreviewController.swift | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index 42b2bc8..3db9b7b 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -29,6 +29,13 @@ struct MarkeeApp: App { NotificationCenter.default.post(name: .zoomIn, object: nil) } .keyboardShortcut("+", modifiers: [.command]) + // Second Zoom In binding: ⌘= (no Shift). The button above is + // bound to "+" (⌘⇧= on US layouts); this alias matches the + // browser-standard ⌘= so users need not hold Shift. + Button("Zoom In") { + NotificationCenter.default.post(name: .zoomIn, object: nil) + } + .keyboardShortcut("=", modifiers: [.command]) Button("Zoom Out") { NotificationCenter.default.post(name: .zoomOut, object: nil) } diff --git a/Sources/Markee/PreviewController.swift b/Sources/Markee/PreviewController.swift index 627b888..8accc0c 100644 --- a/Sources/Markee/PreviewController.swift +++ b/Sources/Markee/PreviewController.swift @@ -379,6 +379,10 @@ final class PreviewController: NSObject, ObservableObject, WKScriptMessageHandle pendingRender = nil render(source: pending) } + // Load-bearing: a window opened after another window changed zoom + // never received that .zoomDidChange broadcast, and a broadcast + // that arrived before this page's JS was ready was a silent no-op. + // Re-reading the persisted level here covers both cases. applyZoom() case "outline": if let items = body["items"] as? [[String: Any]] {