Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Resources/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html> 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)
Expand Down
42 changes: 42 additions & 0 deletions Sources/Markee/MarkeeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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…") {
Expand All @@ -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])
}
}
}
Expand All @@ -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 {
Expand Down
118 changes: 118 additions & 0 deletions Sources/Markee/PreviewController.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) }

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions Tests/MarkeeTests/ZoomTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading