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) diff --git a/Sources/Markee/MarkeeApp.swift b/Sources/Markee/MarkeeApp.swift index edc4e89..3db9b7b 100644 --- a/Sources/Markee/MarkeeApp.swift +++ b/Sources/Markee/MarkeeApp.swift @@ -23,6 +23,33 @@ 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]) + // 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) + } + .keyboardShortcut("-", modifiers: [.command]) + Button("Actual Size") { + 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…") { @@ -45,6 +72,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]) } } } @@ -56,6 +91,13 @@ 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") + 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 6519646..8accc0c 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 @@ -73,6 +95,27 @@ 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) + NotificationCenter.default.addObserver( + self, selector: #selector(handleFindNext), + name: .findNext, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleFindPrevious), + name: .findPrevious, object: nil) + NotificationCenter.default.addObserver( + self, selector: #selector(handleReload), + name: .reloadFile, object: nil) } deinit { @@ -174,6 +217,34 @@ 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() + } + } + + /// ⌘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 } + if findQuery.isEmpty { + showFindBar = true + } else { + findPrevious() + } + } + func findNext() { runFind(backwards: false) } func findPrevious() { runFind(backwards: true) } @@ -202,6 +273,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 } @@ -266,6 +379,11 @@ 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]] { self.outline = items.compactMap { d in 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) + } +}