Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
466a1e655c
|
||
|
|
be68c9f1bb | ||
|
|
77b9274434 | ||
|
|
ab3731e0df | ||
|
|
4536db3f09 | ||
|
|
2bfdf91f77 | ||
|
|
6e25ec7963
|
||
|
|
1ab16bf4ed | ||
|
|
0d1133aafd | ||
|
|
fbb313ccb9 | ||
|
|
53a6ae7896 | ||
|
|
76ed62618c | ||
|
|
cb29d64a1d | ||
|
|
cde9351f11 | ||
|
|
ad2cc7e55f | ||
|
|
05d65cf0c7 | ||
|
|
ff7b942d46 | ||
|
|
01ff200f8d | ||
|
|
c9e8867ec3 | ||
|
|
2c2f153299 | ||
|
|
484d9af0ea | ||
|
|
87f8318f4d | ||
|
|
6d9b5a32f5 |
No files matched your search
@@ -0,0 +1,16 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
tags: ['v*']
|
||||
jobs:
|
||||
macos:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/macos.yaml@v1
|
||||
secrets: inherit
|
||||
with:
|
||||
# Build only: the app and the test bundle are compiled unsigned, the tests are not run. They are
|
||||
# app-hosted XCTests and the grogu runner has no GUI session for testmanagerd; run `just test`
|
||||
# locally before merging (README, Building from Source). Package.swift has no test target.
|
||||
commands: |
|
||||
xcodegen generate
|
||||
xcodebuild -quiet -project markdownViewr.xcodeproj -scheme markdownViewr -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO build-for-testing
|
||||
@@ -1,5 +1,7 @@
|
||||
# markdownViewr
|
||||
|
||||
> **This is KofTwentyTwo's contribution fork of [darinkelkhoff/markdownViewr](https://github.com/darinkelkhoff/markdownViewr).** It publishes no builds. The download, Homebrew cask and Sparkle updates below all come from the upstream project. The fork carries printing work that is offered upstream: native printing merged as [darinkelkhoff/markdownViewr#2](https://github.com/darinkelkhoff/markdownViewr/pull/2), and the print-styling fixes are open as [#3](https://github.com/darinkelkhoff/markdownViewr/pull/3). Report app issues upstream. The Windows port is [KofTwentyTwo/MarkdownViewr.Windows](https://github.com/KofTwentyTwo/MarkdownViewr.Windows).
|
||||
|
||||
A fast, beautiful markdown viewer for macOS. Open any markdown file — it live-reloads as you edit in your favorite editor.
|
||||
|
||||
[](https://github.com/darinkelkhoff/markdownViewr/releases/latest/download/markdownViewr.dmg)
|
||||
@@ -59,3 +61,12 @@ just run
|
||||
```
|
||||
|
||||
`just --list` shows all available recipes.
|
||||
|
||||
### Tests
|
||||
|
||||
The tests are app-hosted XCTests (`markdownViewrTests`). CI builds the app and the test bundle unsigned
|
||||
but does not run them (the CI macOS runner has no GUI session), so run them locally before merging:
|
||||
|
||||
```bash
|
||||
just test # xcodegen generate first if the project is stale
|
||||
```
|
||||
+9
-8
@@ -5,6 +5,15 @@
|
||||
<link>https://github.com/darinkelkhoff/markdownViewr</link>
|
||||
<description>markdownViewr releases</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>1.5.0</title>
|
||||
<pubDate>Thu, 23 Jul 2026 08:33:55 -0500</pubDate>
|
||||
<sparkle:version>10500</sparkle:version>
|
||||
<sparkle:shortVersionString>1.5.0</sparkle:shortVersionString>
|
||||
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.5.0.html</sparkle:releaseNotesLink>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.5.0/markdownViewr-1.5.0.zip" length="4426474" type="application/octet-stream" sparkle:edSignature="sIjjKAMzb8Fv9zjr0vkfbpxeriD+lmkdB/L+wcs/Ay0mcvYu+WIr4UkIpeYBLsLevOl2aYfM3+KOhHdTjAnXDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.4.0</title>
|
||||
<pubDate>Fri, 26 Jun 2026 09:00:05 -0500</pubDate>
|
||||
@@ -23,13 +32,5 @@
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.3.0/markdownViewr-1.3.0.zip" length="4256339" type="application/octet-stream" sparkle:edSignature="TNbtuUw4oI/YHBwiHqtfy4CFTu/pay+R/fGjF/usHw9DWVfKh+tRvgu8UyHVe0SbR7KFX6PGg1aVzeONMicqDw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.2.0</title>
|
||||
<pubDate>Tue, 09 Jun 2026 13:58:18 -0500</pubDate>
|
||||
<sparkle:version>10200</sparkle:version>
|
||||
<sparkle:shortVersionString>1.2.0</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.2.0/markdownViewr-1.2.0.zip" length="4220100" type="application/octet-stream" sparkle:edSignature="To0Yh+F2nAaYfLCwd/nxOR+bMkEwTIS01T1YA0tJNto6VYXc5haNeOU+uX2e4nx3JAHvknGFKWQRunkS1aCSBw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr 1.5.0 Release Notes</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 28px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #1d1d1f;
|
||||
background: #fff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.release-notes {
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 1.8rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin: 0 0 24px;
|
||||
color: #6e6e73;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 24px 0 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 1.35rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
background: #f5f5f7;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { color: #f5f5f7; background: #1c1c1e; }
|
||||
.date { color: #a1a1a6; }
|
||||
code { background: #2c2c2e; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="release-notes">
|
||||
<h1>markdownViewr 1.5.0</h1>
|
||||
<p class="date">Released July 23, 2026</p>
|
||||
|
||||
<h2>Native printing</h2>
|
||||
<ul>
|
||||
<li>Added native macOS printing for rendered Markdown documents.</li>
|
||||
<li>Added a Printing tab in Settings for print theme, images, backgrounds, borders, page padding, zoom, and content width.</li>
|
||||
<li>Added a built-in Clean Printing theme for predictable print output.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Toolbar improvements</h2>
|
||||
<ul>
|
||||
<li>Added an optional Print toolbar item that can be added from Customize Toolbar.</li>
|
||||
<li>Replaced the custom small-space toolbar item with AppKit's native repeatable space item.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Window and navigation polish</h2>
|
||||
<ul>
|
||||
<li>Updated table-of-contents highlighting to follow the mouse cursor instead of just the top-of-window scroll position.</li>
|
||||
<li>Improved tabbed document window behavior by avoiding frame autosave on tab groups.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -67,6 +67,14 @@ release: kill
|
||||
echo "Error: v$VERSION is already released. Bump MARKETING_VERSION in project.yml first."
|
||||
exit 1
|
||||
fi
|
||||
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
|
||||
echo "Error: local tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
|
||||
exit 1
|
||||
fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/v$VERSION" >/dev/null 2>&1; then
|
||||
echo "Error: remote tag v$VERSION already exists. Bump MARKETING_VERSION or delete the stale tag."
|
||||
exit 1
|
||||
fi
|
||||
ARCHIVE="/tmp/markdownViewr.xcarchive"
|
||||
EXPORT="/tmp/markdownViewr-export"
|
||||
ZIP="/tmp/markdownViewr-$VERSION.zip"
|
||||
@@ -112,11 +120,6 @@ release: kill
|
||||
--app-drop-link 480 185 \
|
||||
"$DMG" \
|
||||
"$DMG_STAGING/"
|
||||
echo "==> Creating GitHub release v$VERSION..."
|
||||
gh release create "v$VERSION" "$DMG" "$ZIP" \
|
||||
--repo darinkelkhoff/markdownViewr \
|
||||
--title "markdownViewr v$VERSION" \
|
||||
--generate-notes
|
||||
echo "==> Updating appcast..."
|
||||
SPARKLE_BIN=$(find ~/Library/Developer/Xcode/DerivedData -path "*/artifacts/sparkle/Sparkle/bin" -type d 2>/dev/null | head -1)
|
||||
if [[ -z "$SPARKLE_BIN" ]]; then
|
||||
@@ -133,7 +136,15 @@ release: kill
|
||||
echo "==> Committing and pushing appcast..."
|
||||
git add appcast.xml
|
||||
git commit -m "release: update appcast for v$VERSION"
|
||||
echo "==> Tagging release v$VERSION..."
|
||||
git tag -a "v$VERSION" -m "markdownViewr v$VERSION"
|
||||
git push origin main
|
||||
git push origin "v$VERSION"
|
||||
echo "==> Creating GitHub release v$VERSION..."
|
||||
gh release create "v$VERSION" "$DMG" "$ZIP" \
|
||||
--repo darinkelkhoff/markdownViewr \
|
||||
--title "markdownViewr v$VERSION" \
|
||||
--generate-notes
|
||||
just update-cask
|
||||
echo ""
|
||||
echo "Done! https://github.com/darinkelkhoff/markdownViewr/releases/tag/v$VERSION"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
class LiveContent: ObservableObject {
|
||||
@Published var rawMarkdown: String = ""
|
||||
@@ -46,6 +47,13 @@ struct ContentView: View {
|
||||
@State private var hasInitializedWindowViewState = false
|
||||
@State private var zoomScale = 1.0
|
||||
@State private var activeThemeName: String?
|
||||
@AppStorage("printTheme") private var printTheme = "Clean Printing"
|
||||
@AppStorage("printBackgrounds") private var printBackgrounds = false
|
||||
@AppStorage("printImages") private var printImages = true
|
||||
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
|
||||
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
|
||||
@AppStorage("printBorders") private var printBorders = false
|
||||
@AppStorage("printPagePadding") private var printPagePadding = "0px"
|
||||
|
||||
private var currentMarkdown: String {
|
||||
liveContent.rawMarkdown.isEmpty ? document.rawMarkdown : liveContent.rawMarkdown
|
||||
@@ -144,6 +152,7 @@ struct ContentView: View {
|
||||
setRawVisible: setRawSourceVisible,
|
||||
setZoomScale: { zoomScale = $0 },
|
||||
setActiveThemeName: { activeThemeName = $0 },
|
||||
printDocument: { printActiveDocument() },
|
||||
showMissingEditor: { editorName in
|
||||
missingEditorName = editorName
|
||||
showMissingEditorAlert = true
|
||||
@@ -195,7 +204,8 @@ struct ContentView: View {
|
||||
zoomOut: { zoomScale = max(zoomScale / 1.1, 0.3) },
|
||||
zoomReset: { zoomScale = 1.0 },
|
||||
nextTheme: { cycleTheme(direction: 1) },
|
||||
previousTheme: { cycleTheme(direction: -1) }
|
||||
previousTheme: { cycleTheme(direction: -1) },
|
||||
printDocument: { printActiveDocument() }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,6 +256,83 @@ struct ContentView: View {
|
||||
hasActivatedRawSource = true
|
||||
}
|
||||
|
||||
/// Triggers the native macOS print sheet modal for the active Markdown document.
|
||||
/// It temporarily swaps in the print-specific theme CSS, applies layout class overrides,
|
||||
/// presents the print panel, and restores the original theme when the print flow concludes.
|
||||
@MainActor
|
||||
private func printActiveDocument() {
|
||||
guard let window = findBar.window else { return }
|
||||
guard let webView = window.contentView?.firstSubview(ofType: WKWebView.self) else { return }
|
||||
|
||||
let themeToPrint: Theme?
|
||||
if printTheme == "Active Theme" {
|
||||
themeToPrint = activeTheme
|
||||
} else if printTheme == "Clean Printing" {
|
||||
themeToPrint = Theme.cleanPrinting
|
||||
} else if printTheme == "Plain HTML" {
|
||||
themeToPrint = nil
|
||||
} else {
|
||||
themeToPrint = themeManager.allThemes.first { $0.name == printTheme }
|
||||
?? themeManager.themes.first { $0.name == printTheme }
|
||||
?? Theme.cleanPrinting
|
||||
}
|
||||
|
||||
// Determine zoom scale based on print settings
|
||||
let zoom = PrintZoomResolver.zoomScale(for: printZoom, screenZoom: zoomScale)
|
||||
|
||||
let printCSS = themeToPrint != nil ? themeManager.generateCSS(for: themeToPrint!, zoomScale: zoom) : ""
|
||||
let escapedPrintCSS = printCSS.escapedForJavaScriptLiteral()
|
||||
let prepareJS = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: escapedPrintCSS,
|
||||
stripBackgrounds: !printBackgrounds,
|
||||
stripImages: !printImages,
|
||||
stripWidth: printContentWidth == "Full Page",
|
||||
printBorders: printBorders,
|
||||
pagePadding: printPagePadding
|
||||
)
|
||||
|
||||
let restoreCSS = themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)
|
||||
let printBackgroundsVal = printBackgrounds
|
||||
let printImagesVal = printImages
|
||||
let printContentWidthVal = printContentWidth
|
||||
let printBordersVal = printBorders
|
||||
let printPagePaddingVal = printPagePadding
|
||||
|
||||
webView.evaluateJavaScript(prepareJS) { [weak webView] _, _ in
|
||||
guard let webView = webView else { return }
|
||||
|
||||
guard let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo else { return }
|
||||
printInfo.horizontalPagination = .fit
|
||||
printInfo.verticalPagination = .automatic
|
||||
printInfo.leftMargin = 36
|
||||
printInfo.rightMargin = 36
|
||||
printInfo.topMargin = 36
|
||||
printInfo.bottomMargin = 36
|
||||
|
||||
let printOp = webView.printOperation(with: printInfo)
|
||||
printOp.showsPrintPanel = true
|
||||
printOp.showsProgressPanel = true
|
||||
|
||||
let coordinator = PrintCoordinator(
|
||||
webView: webView,
|
||||
restoreCSS: restoreCSS,
|
||||
stripBackgrounds: !printBackgroundsVal,
|
||||
stripImages: !printImagesVal,
|
||||
stripWidth: printContentWidthVal == "Full Page",
|
||||
printBorders: printBordersVal,
|
||||
pagePadding: printPagePaddingVal
|
||||
)
|
||||
let coordinatorPointer = Unmanaged.passRetained(coordinator).toOpaque()
|
||||
|
||||
printOp.runModal(
|
||||
for: window,
|
||||
delegate: coordinator,
|
||||
didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:)),
|
||||
contextInfo: coordinatorPointer
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum DocumentViewLayout {
|
||||
@@ -279,6 +366,7 @@ struct DocumentViewCommands {
|
||||
let zoomReset: () -> Void
|
||||
let nextTheme: () -> Void
|
||||
let previousTheme: () -> Void
|
||||
let printDocument: () -> Void
|
||||
}
|
||||
|
||||
private struct DocumentViewCommandsKey: FocusedValueKey {
|
||||
@@ -307,6 +395,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
private var setRawVisible: ((Bool) -> Void)?
|
||||
private var setZoomScale: ((Double) -> Void)?
|
||||
private var setActiveThemeName: ((String) -> Void)?
|
||||
private var printDocument: (() -> Void)?
|
||||
private var showMissingEditor: ((String) -> Void)?
|
||||
|
||||
func configure(
|
||||
@@ -324,6 +413,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
setRawVisible: @escaping (Bool) -> Void,
|
||||
setZoomScale: @escaping (Double) -> Void,
|
||||
setActiveThemeName: @escaping (String) -> Void,
|
||||
printDocument: @escaping () -> Void,
|
||||
showMissingEditor: @escaping (String) -> Void
|
||||
) {
|
||||
self.window = window
|
||||
@@ -340,6 +430,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
self.setRawVisible = setRawVisible
|
||||
self.setZoomScale = setZoomScale
|
||||
self.setActiveThemeName = setActiveThemeName
|
||||
self.printDocument = printDocument
|
||||
self.showMissingEditor = showMissingEditor
|
||||
|
||||
Self.removeLegacySavedToolbarConfigurations()
|
||||
@@ -365,7 +456,7 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
}
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .space]
|
||||
return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
@@ -401,6 +492,8 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
return makeThemeItem()
|
||||
case .externalEditor:
|
||||
return makeExternalEditorItem()
|
||||
case .printDocument:
|
||||
return makePrintItem()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -503,6 +596,18 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
return item
|
||||
}
|
||||
|
||||
private func makePrintItem() -> NSToolbarItem {
|
||||
let item = NSToolbarItem(itemIdentifier: .printDocument)
|
||||
item.label = "Print"
|
||||
item.paletteLabel = "Print"
|
||||
item.toolTip = "Print"
|
||||
item.image = NSImage(systemSymbolName: "printer", accessibilityDescription: "Print")
|
||||
item.target = self
|
||||
item.action = #selector(printDocumentFromToolbar)
|
||||
item.menuFormRepresentation = toolbarMenuItem(title: "Print", action: #selector(printDocumentFromToolbar))
|
||||
return item
|
||||
}
|
||||
|
||||
private var externalEditorLabel: String {
|
||||
let validEditors = editorManager?.editors.filter(\.exists) ?? []
|
||||
if validEditors.count == 1 {
|
||||
@@ -725,6 +830,10 @@ final class DocumentToolbarController: NSObject, ObservableObject, NSToolbarDele
|
||||
setZoomScale?(max(zoomScale / 1.1, 0.3))
|
||||
}
|
||||
|
||||
@objc private func printDocumentFromToolbar() {
|
||||
printDocument?()
|
||||
}
|
||||
|
||||
@objc private func actualSize() {
|
||||
setZoomScale?(1.0)
|
||||
}
|
||||
@@ -796,6 +905,7 @@ private extension NSToolbarItem.Identifier {
|
||||
static let zoom = NSToolbarItem.Identifier("zoom")
|
||||
static let theme = NSToolbarItem.Identifier("theme")
|
||||
static let externalEditor = NSToolbarItem.Identifier("external-editor")
|
||||
static let printDocument = NSToolbarItem.Identifier("print-document")
|
||||
}
|
||||
|
||||
private extension NSToolbar.Identifier {
|
||||
@@ -990,3 +1100,151 @@ extension Color {
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSView {
|
||||
func firstSubview<T: NSView>(ofType type: T.Type) -> T? {
|
||||
if let match = self as? T {
|
||||
return match
|
||||
}
|
||||
for subview in subviews {
|
||||
if let match = subview.firstSubview(ofType: type) {
|
||||
return match
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class to coordinate the print sheet operations in macOS.
|
||||
/// Since SwiftUI's `ContentView` is a struct, it cannot receive `@objc` target-action messages.
|
||||
/// This NSObject subclass serves as the delegate for `NSPrintOperation`, ensuring that when the
|
||||
/// modal print sheet closes, the web view is cleanly restored to its screen theme/styles and
|
||||
/// its manually-retained unmanaged reference pointer is released.
|
||||
@MainActor
|
||||
class PrintCoordinator: NSObject {
|
||||
weak var webView: WKWebView?
|
||||
let restoreCSS: String
|
||||
let stripBackgrounds: Bool
|
||||
let stripImages: Bool
|
||||
let stripWidth: Bool
|
||||
let printBorders: Bool
|
||||
let pagePadding: String
|
||||
|
||||
init(webView: WKWebView, restoreCSS: String, stripBackgrounds: Bool, stripImages: Bool, stripWidth: Bool, printBorders: Bool, pagePadding: String) {
|
||||
self.webView = webView
|
||||
self.restoreCSS = restoreCSS
|
||||
self.stripBackgrounds = stripBackgrounds
|
||||
self.stripImages = stripImages
|
||||
self.stripWidth = stripWidth
|
||||
self.printBorders = printBorders
|
||||
self.pagePadding = pagePadding
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Delegate callback executed by the macOS printing system after the print sheet runs.
|
||||
/// It restores visual screen settings and releases the retained coordinate pointer.
|
||||
@objc func printOperationDidRun(
|
||||
_ printOperation: NSPrintOperation,
|
||||
success: Bool,
|
||||
contextInfo: UnsafeMutableRawPointer?
|
||||
) {
|
||||
restore()
|
||||
if let contextInfo = contextInfo {
|
||||
// Balance the manual passRetained call done at instantiation to prevent memory leaks.
|
||||
Unmanaged<PrintCoordinator>.fromOpaque(contextInfo).release()
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the web view's CSS variables and removes active print classes.
|
||||
func restore() {
|
||||
guard let webView = webView else { return }
|
||||
let escapedCSS = restoreCSS.escapedForJavaScriptLiteral()
|
||||
let restoreJS = PrintScriptGenerator.restoreScript(
|
||||
escapedCSS: escapedCSS,
|
||||
stripBackgrounds: stripBackgrounds,
|
||||
stripImages: stripImages,
|
||||
stripWidth: stripWidth,
|
||||
printBorders: printBorders,
|
||||
pagePadding: pagePadding
|
||||
)
|
||||
webView.evaluateJavaScript(restoreJS, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates JavaScript commands for WebKit style updates during print operations.
|
||||
struct PrintScriptGenerator {
|
||||
static func prepareScript(
|
||||
escapedCSS: String,
|
||||
stripBackgrounds: Bool,
|
||||
stripImages: Bool,
|
||||
stripWidth: Bool,
|
||||
printBorders: Bool,
|
||||
pagePadding: String
|
||||
) -> String {
|
||||
let stripBackgroundsJS = stripBackgrounds ? "document.documentElement.classList.add('print-no-backgrounds');" : ""
|
||||
let stripImagesJS = stripImages ? "document.documentElement.classList.add('print-no-images');" : ""
|
||||
let fullWidthJS = stripWidth ? "document.documentElement.classList.add('print-full-width');" : ""
|
||||
let bordersJS = printBorders ? "document.documentElement.classList.add('print-borders');" : ""
|
||||
let paddingJS = "document.documentElement.style.setProperty('--print-padding', '\(pagePadding)');"
|
||||
return """
|
||||
document.getElementById('theme-css').textContent = '\(escapedCSS)';
|
||||
\(stripBackgroundsJS)
|
||||
\(stripImagesJS)
|
||||
\(fullWidthJS)
|
||||
\(bordersJS)
|
||||
\(paddingJS)
|
||||
void document.body.offsetHeight;
|
||||
"""
|
||||
}
|
||||
|
||||
static func restoreScript(
|
||||
escapedCSS: String,
|
||||
stripBackgrounds: Bool,
|
||||
stripImages: Bool,
|
||||
stripWidth: Bool,
|
||||
printBorders: Bool,
|
||||
pagePadding: String
|
||||
) -> String {
|
||||
let removeBgJS = stripBackgrounds ? "document.documentElement.classList.remove('print-no-backgrounds');" : ""
|
||||
let removeImgJS = stripImages ? "document.documentElement.classList.remove('print-no-images');" : ""
|
||||
let removeWidthJS = stripWidth ? "document.documentElement.classList.remove('print-full-width');" : ""
|
||||
let removeBordersJS = printBorders ? "document.documentElement.classList.remove('print-borders');" : ""
|
||||
let removePaddingJS = "document.documentElement.style.removeProperty('--print-padding');"
|
||||
return """
|
||||
document.getElementById('theme-css').textContent = '\(escapedCSS)';
|
||||
\(removeBgJS)
|
||||
\(removeImgJS)
|
||||
\(removeWidthJS)
|
||||
\(removeBordersJS)
|
||||
\(removePaddingJS)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension String {
|
||||
func escapedForJavaScriptLiteral() -> String {
|
||||
self.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "'", with: "\\'")
|
||||
.replacingOccurrences(of: "\r", with: "")
|
||||
.replacingOccurrences(of: "\n", with: "\\n")
|
||||
}
|
||||
}
|
||||
|
||||
enum PrintZoomResolver {
|
||||
static func zoomScale(for printZoom: String, screenZoom: Double) -> Double {
|
||||
switch printZoom {
|
||||
case "Compact (75%)":
|
||||
return 0.75
|
||||
case "Small (85%)":
|
||||
return 0.85
|
||||
case "Standard (100%)":
|
||||
return 1.0
|
||||
case "Large (115%)":
|
||||
return 1.15
|
||||
case "Match Screen":
|
||||
return screenZoom
|
||||
default:
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -275,7 +275,7 @@ struct MarkdownDocument: FileDocument {
|
||||
private struct HTMLConverter: MarkupWalker {
|
||||
private var result = ""
|
||||
|
||||
mutating func visit(_ document: Document) -> String {
|
||||
mutating func visit(_ document: Markdown.Document) -> String {
|
||||
result = ""
|
||||
for child in document.children {
|
||||
visitChild(child)
|
||||
|
||||
@@ -55,6 +55,14 @@ struct MarkdownViewrApp: App {
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .printItem) {
|
||||
Button("Print...") {
|
||||
documentViewCommands?.printDocument()
|
||||
}
|
||||
.keyboardShortcut("p", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .textEditing) {
|
||||
Button("Find...") {
|
||||
NotificationCenter.default.post(name: .findToggle, object: nil)
|
||||
@@ -169,8 +177,10 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
return NSSize(width: width, height: 800)
|
||||
}
|
||||
|
||||
static func shouldApplyDocumentFrame(isTabbedWindow: Bool) -> Bool {
|
||||
!isTabbedWindow
|
||||
private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()
|
||||
|
||||
static func shouldApplyDocumentFrame(isTabbedWindow: Bool, hasTabbedHistory: Bool = false) -> Bool {
|
||||
!isTabbedWindow && !hasTabbedHistory
|
||||
}
|
||||
|
||||
static func autosaveName(for fileURL: URL) -> String {
|
||||
@@ -201,17 +211,28 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
|
||||
private func configureWindow(for view: NSView, context: Context) {
|
||||
guard let window = view.window, let fileURL else { return }
|
||||
window.isRestorable = false
|
||||
let autosaveName = Self.autosaveName(for: fileURL)
|
||||
guard context.coordinator.needsConfiguration(for: window, autosaveName: autosaveName) else { return }
|
||||
|
||||
if Self.shouldApplyDocumentFrame(isTabbedWindow: Self.isTabbedWindow(window)) {
|
||||
let isTabbedWindow = Self.isTabbedWindow(window)
|
||||
if isTabbedWindow {
|
||||
Self.markTabGroupHasTabbedHistory(window)
|
||||
context.coordinator.stopSavingFrame(for: window)
|
||||
return
|
||||
}
|
||||
|
||||
let hasTabbedHistory = Self.windowHasTabbedHistory(window)
|
||||
if Self.shouldApplyDocumentFrame(isTabbedWindow: isTabbedWindow, hasTabbedHistory: hasTabbedHistory) {
|
||||
let restoredFrame = window.setFrameUsingName(autosaveName)
|
||||
if !restoredFrame {
|
||||
window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))
|
||||
window.center()
|
||||
}
|
||||
}
|
||||
window.setFrameAutosaveName(autosaveName)
|
||||
if !hasTabbedHistory {
|
||||
window.setFrameAutosaveName(autosaveName)
|
||||
}
|
||||
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
|
||||
}
|
||||
|
||||
@@ -219,6 +240,19 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
window.tabbedWindows?.contains { $0 !== window } == true
|
||||
}
|
||||
|
||||
private static func markTabGroupHasTabbedHistory(_ window: NSWindow) {
|
||||
let tabGroupWindows = window.tabbedWindows ?? [window]
|
||||
tabGroupWindows.forEach {
|
||||
windowsWithTabbedHistory.add($0)
|
||||
$0.isRestorable = false
|
||||
$0.setFrameAutosaveName("")
|
||||
}
|
||||
}
|
||||
|
||||
private static func windowHasTabbedHistory(_ window: NSWindow) -> Bool {
|
||||
windowsWithTabbedHistory.contains(window)
|
||||
}
|
||||
|
||||
class Coordinator {
|
||||
private weak var configuredWindow: NSWindow?
|
||||
private var configuredAutosaveName: String?
|
||||
@@ -260,6 +294,12 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
window.saveFrame(usingName: autosaveName)
|
||||
}
|
||||
|
||||
func stopSavingFrame(for window: NSWindow) {
|
||||
removeObservers()
|
||||
configuredWindow = nil
|
||||
configuredAutosaveName = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ body {
|
||||
#toc a.active {
|
||||
opacity: 1;
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 0.35px currentColor;
|
||||
}
|
||||
|
||||
#toc .toc-h2 { padding-left: 18px; }
|
||||
@@ -408,6 +408,174 @@ mark {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
display: block !important;
|
||||
background-color: var(--bg) !important;
|
||||
color: var(--text) !important;
|
||||
font-family: var(--body-font) !important;
|
||||
font-size: var(--base-font-size) !important;
|
||||
line-height: var(--line-height) !important;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: var(--heading-font) !important;
|
||||
}
|
||||
|
||||
h1 { color: var(--h1) !important; font-size: var(--h1-size) !important; }
|
||||
h2 { color: var(--h2) !important; font-size: var(--h2-size) !important; }
|
||||
h3 { color: var(--h3) !important; font-size: var(--h3-size) !important; }
|
||||
h4 { color: var(--h4) !important; font-size: var(--h4-size) !important; }
|
||||
h5 { color: var(--h5) !important; font-size: var(--h5-size) !important; }
|
||||
h6 { color: var(--h6) !important; font-size: var(--h6-size) !important; }
|
||||
|
||||
a { color: var(--link) !important; }
|
||||
|
||||
pre { background-color: var(--code-bg) !important; }
|
||||
pre code, code {
|
||||
font-family: var(--code-font) !important;
|
||||
font-size: var(--code-font-size) !important;
|
||||
color: var(--code-text) !important;
|
||||
}
|
||||
code { background-color: var(--code-bg) !important; }
|
||||
|
||||
blockquote {
|
||||
border-left-color: var(--blockquote-border) !important;
|
||||
background-color: var(--blockquote-bg) !important;
|
||||
}
|
||||
|
||||
mark {
|
||||
background-color: var(--highlight-bg) !important;
|
||||
color: var(--highlight-text) !important;
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
overflow: visible !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
#content-inner {
|
||||
padding: var(--print-padding, 0px) !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* Force collapsed editor sections to print expanded */
|
||||
#content-inner > * {
|
||||
display: revert !important;
|
||||
}
|
||||
|
||||
html.print-full-width #content-inner {
|
||||
max-width: none !important;
|
||||
}
|
||||
|
||||
#toc, #toc-resizer, #raw-resizer, #raw-source, .collapse-arrow, .resizer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html.print-no-backgrounds, html.print-no-backgrounds body {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
|
||||
html.print-no-backgrounds * {
|
||||
-webkit-print-color-adjust: economy !important;
|
||||
print-color-adjust: economy !important;
|
||||
}
|
||||
|
||||
html.print-no-backgrounds pre,
|
||||
html.print-no-backgrounds code,
|
||||
html.print-no-backgrounds blockquote,
|
||||
html.print-no-backgrounds mark,
|
||||
html.print-no-backgrounds .frontmatter {
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
/* Maintain link readability in ink-saving mode */
|
||||
html.print-no-backgrounds a {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
/* Ensure rules and borders are visible in ink-saving mode */
|
||||
html.print-no-backgrounds hr {
|
||||
border-top: 1px solid #dddddd !important;
|
||||
}
|
||||
html.print-no-backgrounds th,
|
||||
html.print-no-backgrounds td {
|
||||
border: 1px solid #dddddd !important;
|
||||
}
|
||||
html.print-no-backgrounds blockquote {
|
||||
border-left: 3px solid #cccccc !important;
|
||||
}
|
||||
html.print-no-backgrounds h1 {
|
||||
border-bottom: 1px solid #dddddd !important;
|
||||
}
|
||||
html.print-no-backgrounds h2 {
|
||||
border-bottom: 1px solid #eeeeee !important;
|
||||
}
|
||||
|
||||
/* Add borders to content blocks that have custom background colors when print-borders is active */
|
||||
html.print-borders pre,
|
||||
html.print-borders .frontmatter {
|
||||
border: 1px solid color-mix(in srgb, var(--text) 25%, transparent) !important;
|
||||
}
|
||||
|
||||
html.print-borders blockquote {
|
||||
border: 1px solid color-mix(in srgb, var(--text) 20%, transparent) !important;
|
||||
border-left: 4px solid var(--blockquote-border) !important;
|
||||
}
|
||||
|
||||
html.print-borders code {
|
||||
border: 1px solid color-mix(in srgb, var(--text) 30%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Specific override to strip images when printImages is false */
|
||||
html.print-no-images #content-inner img,
|
||||
html.print-no-images #content-inner > img {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Print Layout/Rendering Optimizations */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
break-after: avoid !important;
|
||||
page-break-after: avoid !important;
|
||||
}
|
||||
|
||||
/* Keep lists and table rows unbroken on page boundaries */
|
||||
pre, blockquote, tr, li {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100% !important;
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
thead {
|
||||
display: table-header-group !important;
|
||||
}
|
||||
|
||||
th, td {
|
||||
word-wrap: break-word !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style id="theme-css">
|
||||
{{THEME_CSS}}
|
||||
@@ -440,6 +608,7 @@ function updateContent(html) {
|
||||
var content = _contentScroller();
|
||||
var scrollRatio = content.scrollTop /
|
||||
Math.max(1, content.scrollHeight - content.clientHeight);
|
||||
_tocHoverHeadingId = null;
|
||||
_contentContainer().innerHTML = html;
|
||||
var newMax = content.scrollHeight - content.clientHeight;
|
||||
content.scrollTop = scrollRatio * newMax;
|
||||
@@ -650,6 +819,8 @@ var _tocVisible = false;
|
||||
var _tocWidth = 220;
|
||||
var _tocWrap = false;
|
||||
var _tocBullets = false;
|
||||
var _tocHoverHeadingId = null;
|
||||
var _tocHoverHighlightSetup = false;
|
||||
var _rawVisible = false;
|
||||
var _rawWidth = 400;
|
||||
var _syncContentHeadings = [];
|
||||
@@ -1008,6 +1179,7 @@ function setupTOCScrollHighlight() {
|
||||
var timeout;
|
||||
var content = _contentScroller();
|
||||
content.addEventListener('scroll', function() {
|
||||
if (_tocHoverHeadingId) return;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(highlightActiveTOC, 50);
|
||||
});
|
||||
@@ -1036,7 +1208,36 @@ function _scrollTOCLinkIntoView(link) {
|
||||
toc.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function _tocLinkForHeadingId(headingId) {
|
||||
var toc = document.getElementById('toc');
|
||||
var links = toc.querySelectorAll('a');
|
||||
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
if (links[i].dataset.headingId === headingId) return links[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function highlightTOCHeading(headingId, scrollIntoView) {
|
||||
var toc = document.getElementById('toc');
|
||||
if (!toc.classList.contains('visible')) return;
|
||||
|
||||
var links = toc.querySelectorAll('a');
|
||||
var activeLink = _tocLinkForHeadingId(headingId);
|
||||
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
links[i].classList.remove('active');
|
||||
}
|
||||
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
if (scrollIntoView) _scrollTOCLinkIntoView(activeLink);
|
||||
}
|
||||
}
|
||||
|
||||
function highlightActiveTOC() {
|
||||
if (_tocHoverHeadingId) return;
|
||||
|
||||
var toc = document.getElementById('toc');
|
||||
if (!toc.classList.contains('visible')) return;
|
||||
|
||||
@@ -1054,16 +1255,116 @@ function highlightActiveTOC() {
|
||||
if (targetTop <= scrollTop + 60) {
|
||||
activeLink = links[i];
|
||||
}
|
||||
links[i].classList.remove('active');
|
||||
}
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
_scrollTOCLinkIntoView(activeLink);
|
||||
highlightTOCHeading(activeLink.dataset.headingId, true);
|
||||
}
|
||||
}
|
||||
|
||||
function _lastHeadingInElement(element) {
|
||||
if (element.matches && element.matches('h1, h2, h3, h4, h5, h6')) return element;
|
||||
var headings = element.querySelectorAll ? element.querySelectorAll('h1, h2, h3, h4, h5, h6') : [];
|
||||
return headings.length ? headings[headings.length - 1] : null;
|
||||
}
|
||||
|
||||
function _headingForHoveredContent(element) {
|
||||
var content = _contentContainer();
|
||||
if (!element || !content.contains(element)) return null;
|
||||
|
||||
var heading = element.closest ? element.closest('h1, h2, h3, h4, h5, h6') : null;
|
||||
if (heading && content.contains(heading)) return heading;
|
||||
|
||||
var node = element;
|
||||
while (node && node !== content) {
|
||||
var sibling = node.previousElementSibling;
|
||||
while (sibling) {
|
||||
var found = _lastHeadingInElement(sibling);
|
||||
if (found) return found;
|
||||
sibling = sibling.previousElementSibling;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _headingForHoverPosition(clientY) {
|
||||
var content = _contentContainer();
|
||||
var headings = content.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
var activeHeading = null;
|
||||
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var heading = headings[i];
|
||||
if (heading.getBoundingClientRect().top <= clientY) {
|
||||
activeHeading = heading;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return activeHeading;
|
||||
}
|
||||
|
||||
function _headingIdForRawHoverPosition(clientY) {
|
||||
var rawHeading = null;
|
||||
for (var i = 0; i < _syncRawHeadings.length; i++) {
|
||||
var heading = _syncRawHeadings[i];
|
||||
if (heading.getBoundingClientRect().top <= clientY) {
|
||||
rawHeading = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawHeading === null) return null;
|
||||
return _tocHeadingIdForHover(_syncContentHeadings[rawHeading]);
|
||||
}
|
||||
|
||||
function _tocHeadingIdForHover(heading) {
|
||||
while (heading) {
|
||||
if (heading.id && _tocLinkForHeadingId(heading.id)) return heading.id;
|
||||
heading = _headingForHoveredContent(heading.previousElementSibling || heading.parentElement);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _applyTOCHoverHeadingId(headingId) {
|
||||
_tocHoverHeadingId = headingId;
|
||||
if (_tocHoverHeadingId) {
|
||||
highlightTOCHeading(_tocHoverHeadingId, false);
|
||||
} else {
|
||||
highlightActiveTOC();
|
||||
}
|
||||
}
|
||||
|
||||
function setupTOCHoverHighlight() {
|
||||
if (_tocHoverHighlightSetup) return;
|
||||
_tocHoverHighlightSetup = true;
|
||||
|
||||
var content = _contentScroller();
|
||||
var raw = document.getElementById('raw-source');
|
||||
content.addEventListener('mousemove', function(event) {
|
||||
if (!_tocVisible) return;
|
||||
var heading = _headingForHoverPosition(event.clientY);
|
||||
_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading));
|
||||
});
|
||||
content.addEventListener('mouseleave', function() {
|
||||
_tocHoverHeadingId = null;
|
||||
highlightActiveTOC();
|
||||
});
|
||||
if (raw) {
|
||||
raw.addEventListener('mousemove', function(event) {
|
||||
if (!_tocVisible || !_rawVisible) return;
|
||||
_applyTOCHoverHeadingId(_headingIdForRawHoverPosition(event.clientY));
|
||||
});
|
||||
raw.addEventListener('mouseleave', function() {
|
||||
_tocHoverHeadingId = null;
|
||||
highlightActiveTOC();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupCollapsibleHeadings();
|
||||
setupTOCHoverHighlight();
|
||||
if (_tocVisible) {
|
||||
buildTOC();
|
||||
setupTOCScrollHighlight();
|
||||
@@ -1119,9 +1420,26 @@ function _vimSendMessage(action) {
|
||||
}
|
||||
}
|
||||
|
||||
function _handleCommandArrowScroll(e) {
|
||||
if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false;
|
||||
if (e.key === 'ArrowUp') {
|
||||
_contentScroller().scrollTo({ top: 0, behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
var content = _contentScroller();
|
||||
content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' });
|
||||
e.preventDefault();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', function(e) {
|
||||
var t = e.target;
|
||||
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if (_handleCommandArrowScroll(e)) return;
|
||||
if (e.metaKey || e.altKey) return;
|
||||
|
||||
var key = e.key;
|
||||
|
||||
@@ -23,6 +23,11 @@ struct SettingsView: View {
|
||||
.tabItem {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
|
||||
PrintingSettingsView()
|
||||
.tabItem {
|
||||
Label("Printing", systemImage: "printer")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
@@ -481,6 +486,144 @@ struct MarkdownSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct PrintingSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@AppStorage("printTheme") private var printTheme = "Clean Printing"
|
||||
@AppStorage("printBackgrounds") private var printBackgrounds = false
|
||||
@AppStorage("printImages") private var printImages = true
|
||||
@AppStorage("printZoom") private var printZoom = "Standard (100%)"
|
||||
@AppStorage("printContentWidth") private var printContentWidth = "Full Page"
|
||||
@AppStorage("printBorders") private var printBorders = false
|
||||
@AppStorage("printPagePadding") private var printPagePadding = "0px"
|
||||
|
||||
var body: some View {
|
||||
SettingsScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Printing")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox(label: Text("Print Theme")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Theme")
|
||||
Spacer()
|
||||
Picker("Print Theme", selection: $printTheme) {
|
||||
Text("Active Screen Theme").tag("Active Theme")
|
||||
Text("Plain HTML (Unstyled)").tag("Plain HTML")
|
||||
Text("Clean Printing").tag("Clean Printing")
|
||||
Divider()
|
||||
ForEach(themeManager.themes, id: \.name) { theme in
|
||||
Text(theme.name).tag(theme.name)
|
||||
}
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select the theme to use when printing or exporting to PDF.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
|
||||
GroupBox(label: Text("Page Layout")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Include Background Colors", isOn: $printBackgrounds)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, code block backgrounds, blockquotes, and highlights will print with their theme colors. Disable to save ink.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Toggle("Include Images", isOn: $printImages)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, inline and document images will print. Disable to save color ink and space.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
Toggle("Include Content Borders", isOn: $printBorders)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
Text("When enabled, adds borders to styled blocks like code blocks, blockquotes, and tables to guarantee contrast with page background.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Text("Page Padding")
|
||||
Spacer()
|
||||
Picker("Page Padding", selection: $printPagePadding) {
|
||||
Text("None (0px)").tag("0px")
|
||||
Text("Small (24px)").tag("24px")
|
||||
Text("Medium (48px)").tag("48px")
|
||||
Text("Large (72px)").tag("72px")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Add margins inside the background color container to keep text away from the page edge.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox(label: Text("Zoom & Scaling")) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Font Scaling")
|
||||
Spacer()
|
||||
Picker("Font Scaling", selection: $printZoom) {
|
||||
Text("Compact (75%)").tag("Compact (75%)")
|
||||
Text("Small (85%)").tag("Small (85%)")
|
||||
Text("Standard (100%)").tag("Standard (100%)")
|
||||
Text("Large (115%)").tag("Large (115%)")
|
||||
Divider()
|
||||
Text("Match Screen").tag("Match Screen")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select the scale for printed document fonts, or match current screen zoom.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Divider().padding(.vertical, 4)
|
||||
|
||||
HStack {
|
||||
Text("Content Width")
|
||||
Spacer()
|
||||
Picker("Content Width", selection: $printContentWidth) {
|
||||
Text("Full Page Width").tag("Full Page")
|
||||
Text("Match Screen Width").tag("Match Screen")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select whether page layout uses full printable bounds or conforms to screen width guidelines.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThemeEditorItem: Identifiable {
|
||||
let id = UUID()
|
||||
let theme: Theme?
|
||||
|
||||
@@ -172,3 +172,41 @@ struct ThemeSizes: Codable, Hashable {
|
||||
self.lineHeight = lineHeight
|
||||
}
|
||||
}
|
||||
|
||||
extension Theme {
|
||||
static let cleanPrinting = Theme(
|
||||
name: "Clean Printing",
|
||||
colors: ThemeColors(
|
||||
background: "#ffffff",
|
||||
text: "#24292e",
|
||||
heading1: "#1b1f23",
|
||||
heading2: "#1b1f23",
|
||||
heading3: "#1b1f23",
|
||||
heading4: "#1b1f23",
|
||||
heading5: "#1b1f23",
|
||||
heading6: "#1b1f23",
|
||||
link: "#0366d6",
|
||||
codeBackground: "#f6f8fa",
|
||||
codeText: "#24292e",
|
||||
blockquoteBorder: "#dfe2e5",
|
||||
blockquoteBackground: "#fafbfc",
|
||||
highlightBackground: "#fff5b1",
|
||||
highlightText: "#24292e"
|
||||
),
|
||||
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
|
||||
sizes: ThemeSizes(
|
||||
baseFontSize: 11,
|
||||
h1Size: 22,
|
||||
h2Size: 18,
|
||||
h3Size: 15,
|
||||
h4Size: 13.5,
|
||||
h5Size: 12,
|
||||
h6Size: 11,
|
||||
codeFontSize: 10.5,
|
||||
lineHeight: 1.5
|
||||
),
|
||||
customCSS: "",
|
||||
isBuiltIn: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import XCTest
|
||||
import WebKit
|
||||
@testable import markdownViewr
|
||||
|
||||
final class PrintingTests: XCTestCase {
|
||||
// 1. Unit Test for firstSubview(ofType:)
|
||||
func testNSViewFirstSubviewOfTypeRecurse() {
|
||||
let parent = NSView()
|
||||
let mid = NSView()
|
||||
let target = WKWebView()
|
||||
|
||||
mid.addSubview(target)
|
||||
parent.addSubview(mid)
|
||||
|
||||
// Assert we can find the WKWebView recursively
|
||||
let foundWebView = parent.firstSubview(ofType: WKWebView.self)
|
||||
XCTAssertNotNil(foundWebView)
|
||||
XCTAssertEqual(foundWebView, target)
|
||||
|
||||
// Assert we find nil for a type that doesn't exist
|
||||
class CustomTestView: NSView {}
|
||||
XCTAssertNil(parent.firstSubview(ofType: CustomTestView.self))
|
||||
}
|
||||
|
||||
// 2. Static Assertions on source files
|
||||
func testAppConfiguresReplacementPrintCommand() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
// Verify we replaced the default print command group
|
||||
XCTAssertTrue(appSource.contains("CommandGroup(replacing: .printItem)"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.printDocument()"))
|
||||
}
|
||||
|
||||
func testContentViewBindsPrintDocumentActionAndCoordinator() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
// Verify printDocument is in DocumentViewCommands struct
|
||||
XCTAssertTrue(contentSource.contains("let printDocument: () -> Void"))
|
||||
// Verify printDocument is instantiated with printActiveDocument
|
||||
XCTAssertTrue(contentSource.contains("printDocument: { printActiveDocument() }"))
|
||||
// Verify printActiveDocument exists
|
||||
XCTAssertTrue(contentSource.contains("func printActiveDocument()"))
|
||||
// Verify recursive traversal is used to find WKWebView
|
||||
XCTAssertTrue(contentSource.contains("window.contentView?.firstSubview(ofType: WKWebView.self)"))
|
||||
|
||||
// Verify print settings storage variables exist
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
|
||||
|
||||
// Verify print coordinator instantiation passing all parameters
|
||||
XCTAssertTrue(contentSource.contains("let coordinator = PrintCoordinator("))
|
||||
XCTAssertTrue(contentSource.contains("stripBackgrounds: !printBackgroundsVal"))
|
||||
XCTAssertTrue(contentSource.contains("stripImages: !printImagesVal"))
|
||||
XCTAssertTrue(contentSource.contains("stripWidth: printContentWidthVal == \"Full Page\""))
|
||||
|
||||
// Verify we run the print operation modally with coordinator pointer
|
||||
XCTAssertTrue(contentSource.contains("printOp.runModal("))
|
||||
XCTAssertTrue(contentSource.contains("didRun: #selector(PrintCoordinator.printOperationDidRun(_:success:contextInfo:))"))
|
||||
XCTAssertTrue(contentSource.contains("contextInfo: coordinatorPointer"))
|
||||
|
||||
// Verify PrintCoordinator class exists, conforms to @MainActor and supports stripWidth
|
||||
XCTAssertTrue(contentSource.contains("@MainActor\nclass PrintCoordinator"))
|
||||
XCTAssertTrue(contentSource.contains("let stripImages: Bool"))
|
||||
XCTAssertTrue(contentSource.contains("let stripWidth: Bool"))
|
||||
XCTAssertTrue(contentSource.contains("func restore()"))
|
||||
}
|
||||
|
||||
func testPrintOperationUsesCopiedPrintInfo() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("NSPrintInfo.shared.copy() as? NSPrintInfo"))
|
||||
XCTAssertFalse(contentSource.contains("let printInfo = NSPrintInfo.shared\n"))
|
||||
}
|
||||
|
||||
func testPrintToolbarItemIsAvailableForCustomization() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("printDocument: @escaping () -> Void"))
|
||||
XCTAssertTrue(contentSource.contains("self.printDocument = printDocument"))
|
||||
XCTAssertTrue(contentSource.contains(".printDocument, .space"))
|
||||
XCTAssertFalse(contentSource.contains("return [.toc, .tocDepth, .printDocument"))
|
||||
XCTAssertTrue(contentSource.contains("case .printDocument:\n return makePrintItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Print""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.paletteLabel = "Print""#))
|
||||
XCTAssertTrue(contentSource.contains(#"NSImage(systemSymbolName: "printer""#))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(printDocumentFromToolbar)"))
|
||||
XCTAssertTrue(contentSource.contains("@objc private func printDocumentFromToolbar()"))
|
||||
XCTAssertTrue(contentSource.contains("printDocument?()"))
|
||||
XCTAssertTrue(contentSource.contains(#"static let printDocument = NSToolbarItem.Identifier("print-document")"#))
|
||||
}
|
||||
|
||||
func testXcodeGenConfigurationIncludesTestSources() throws {
|
||||
let projectSource = try loadSourceFile("project.yml")
|
||||
|
||||
XCTAssertTrue(projectSource.contains("markdownViewrTests:"))
|
||||
XCTAssertTrue(projectSource.contains("- markdownViewrTests"))
|
||||
}
|
||||
|
||||
func testSettingsViewIncludesPrintingTab() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
|
||||
// Verify PrintingSettingsView is declared in TabView
|
||||
XCTAssertTrue(settingsSource.contains("PrintingSettingsView()"))
|
||||
XCTAssertTrue(settingsSource.contains(#"Label("Printing", systemImage: "printer")"#))
|
||||
|
||||
// Verify PrintingSettingsView struct layout
|
||||
XCTAssertTrue(settingsSource.contains("struct PrintingSettingsView: View"))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printTheme") private var printTheme = "Clean Printing""#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printBackgrounds") private var printBackgrounds = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printImages") private var printImages = true"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printZoom") private var printZoom = "Standard (100%)""#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("printContentWidth") private var printContentWidth = "Full Page""#))
|
||||
|
||||
// Verify spacing and alignment matching HIG guidelines
|
||||
XCTAssertTrue(settingsSource.contains("VStack(alignment: .leading, spacing: 0)"))
|
||||
XCTAssertTrue(settingsSource.contains(".padding(.bottom, 16)"))
|
||||
XCTAssertTrue(settingsSource.contains(".padding(.top, 12)"))
|
||||
|
||||
// Verify Settings Layout Components (splits and columns)
|
||||
XCTAssertTrue(settingsSource.contains("Picker("))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Plain HTML (Unstyled)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Clean Printing")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Print Theme"))"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Page Layout"))"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"GroupBox(label: Text("Zoom & Scaling"))"#))
|
||||
XCTAssertTrue(settingsSource.contains("Toggle("))
|
||||
}
|
||||
|
||||
// 3. Template @media print assertion
|
||||
func testTemplateIncludesPrintMediaQueries() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
// Verify print media query exists
|
||||
XCTAssertTrue(template.contains("@media print"))
|
||||
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Verify print color adjust properties are present to preserve theme colors
|
||||
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
|
||||
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
|
||||
|
||||
// Verify print background toggling override style exists without wiping theme text colors
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds"))
|
||||
XCTAssertTrue(printBlock.contains("print-color-adjust: economy !important"))
|
||||
XCTAssertFalse(printBlock.contains("color: #000000 !important"))
|
||||
|
||||
// Verify ink-saving layout overrides for links, rules, headers, and blockquotes
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
|
||||
|
||||
// Verify print image toggling override style targets specific content images
|
||||
XCTAssertTrue(printBlock.contains("html.print-no-images #content-inner img"))
|
||||
|
||||
// Verify collapsed editor expansion rules
|
||||
XCTAssertTrue(printBlock.contains("#content-inner > *"))
|
||||
XCTAssertTrue(printBlock.contains("display: revert !important"))
|
||||
|
||||
// Verify print full width toggling override style exists
|
||||
XCTAssertTrue(printBlock.contains("html.print-full-width #content-inner"))
|
||||
XCTAssertTrue(printBlock.contains("max-width: none !important"))
|
||||
|
||||
// Verify layout overrides
|
||||
XCTAssertTrue(printBlock.contains("html, body"))
|
||||
XCTAssertTrue(printBlock.contains("height: auto !important"))
|
||||
XCTAssertTrue(printBlock.contains("overflow: visible !important"))
|
||||
|
||||
XCTAssertTrue(printBlock.contains("#content"))
|
||||
|
||||
// Verify elements hidden
|
||||
XCTAssertTrue(printBlock.contains("#toc"))
|
||||
XCTAssertTrue(printBlock.contains("#raw-source"))
|
||||
XCTAssertTrue(printBlock.contains(".collapse-arrow"))
|
||||
XCTAssertTrue(printBlock.contains("display: none !important"))
|
||||
}
|
||||
|
||||
func testTemplatePrintStylesForEdgeCases() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// 1. Heading Orphaning: Ensure headings (h1-h6) have CSS break-after avoid rules
|
||||
XCTAssertTrue(printBlock.contains("h1, h2, h3, h4, h5, h6 {"))
|
||||
XCTAssertTrue(printBlock.contains("break-after: avoid"))
|
||||
XCTAssertTrue(printBlock.contains("page-break-after: avoid"))
|
||||
|
||||
// 2. Element Page Splits: Ensure small code blocks (pre/code), blockquotes, table rows, and list items have break-inside/page-break-inside avoid rules
|
||||
XCTAssertTrue(printBlock.contains("pre, blockquote, tr, li {"))
|
||||
XCTAssertTrue(printBlock.contains("break-inside: avoid"))
|
||||
XCTAssertTrue(printBlock.contains("page-break-inside: avoid"))
|
||||
|
||||
// 3. Image Overflow: Ensure images do not exceed page boundaries and break-inside: avoid
|
||||
XCTAssertTrue(printBlock.contains("img {"))
|
||||
XCTAssertTrue(printBlock.contains("max-width: 100%"))
|
||||
|
||||
// 4. Table Formatting: Ensure table headers repeat on page boundaries and headers/cells wrap text nicely
|
||||
XCTAssertTrue(printBlock.contains("thead {"))
|
||||
XCTAssertTrue(printBlock.contains("display: table-header-group"))
|
||||
XCTAssertTrue(printBlock.contains("th, td {"))
|
||||
XCTAssertTrue(printBlock.contains("word-wrap: break-word"))
|
||||
XCTAssertTrue(printBlock.contains("white-space: normal"))
|
||||
}
|
||||
|
||||
func testPrintMediaQueriesPreserveThemeColorsInInkSavingMode() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Ensure print-no-backgrounds class does not apply universal color: #000000 !important
|
||||
// so that selected theme CSS variables (--h1, --h2, --link, --code-text, --text) apply during print.
|
||||
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds * {\n -webkit-print-color-adjust: economy !important;\n print-color-adjust: economy !important;\n background: transparent !important;\n background-color: transparent !important;\n background-image: none !important;\n /* Avoid light text rendering on white backgrounds */\n color: #000000 !important;\n }"))
|
||||
XCTAssertFalse(printBlock.contains("html.print-no-backgrounds a {\n color: #0969da !important;"))
|
||||
}
|
||||
|
||||
func testPrintMediaQueriesEnforceThemeFontsSizesAndColors() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
// Verify fonts are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--body-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--base-font-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--heading-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-font) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-font-size) !important"))
|
||||
|
||||
// Verify heading sizes are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--h1-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h2-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h3-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h4-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h5-size) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h6-size) !important"))
|
||||
|
||||
// Verify colors are explicitly bound in @media print
|
||||
XCTAssertTrue(printBlock.contains("var(--h1) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h2) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h3) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h4) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h5) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--h6) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--link) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-text) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--code-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--blockquote-border) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--blockquote-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--highlight-bg) !important"))
|
||||
XCTAssertTrue(printBlock.contains("var(--highlight-text) !important"))
|
||||
}
|
||||
|
||||
func testPrintPrepareScriptForcesLayoutPass() {
|
||||
let script = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "body { color: red; }",
|
||||
stripBackgrounds: false,
|
||||
stripImages: false,
|
||||
stripWidth: false,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(script.contains("document.body.offsetHeight;"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testEveryThemeOptionIsRespectedAndGeneratesValidPrintCSS() {
|
||||
let manager = ThemeManager()
|
||||
let themesToTest = manager.allThemes
|
||||
|
||||
XCTAssertFalse(themesToTest.isEmpty, "ThemeManager should load built-in themes")
|
||||
|
||||
for theme in themesToTest {
|
||||
let css = manager.generateCSS(for: theme, zoomScale: 1.0)
|
||||
XCTAssertFalse(css.isEmpty, "Theme '\(theme.name)' should generate non-empty CSS")
|
||||
XCTAssertTrue(css.contains("--bg: \(theme.colors.background)"), "Theme '\(theme.name)' should include background color")
|
||||
XCTAssertTrue(css.contains("--text: \(theme.colors.text)"), "Theme '\(theme.name)' should include text color")
|
||||
XCTAssertTrue(css.contains("--h1: \(theme.colors.heading1)"), "Theme '\(theme.name)' should include h1 color")
|
||||
XCTAssertTrue(css.contains("--link: \(theme.colors.link)"), "Theme '\(theme.name)' should include link color")
|
||||
XCTAssertTrue(css.contains("--code-bg: \(theme.colors.codeBackground)"), "Theme '\(theme.name)' should include code bg color")
|
||||
XCTAssertTrue(css.contains("--code-text: \(theme.colors.codeText)"), "Theme '\(theme.name)' should include code text color")
|
||||
|
||||
let prepareScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: css.replacingOccurrences(of: "\n", with: "\\n"),
|
||||
stripBackgrounds: false,
|
||||
stripImages: false,
|
||||
stripWidth: true,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent ="), "Script for '\(theme.name)' should target theme-css element")
|
||||
XCTAssertTrue(prepareScript.contains(theme.colors.background), "Prepared script for '\(theme.name)' should inject background color")
|
||||
}
|
||||
|
||||
// Verify Clean Printing option
|
||||
let cleanCSS = manager.generateCSS(for: Theme.cleanPrinting, zoomScale: 1.0)
|
||||
XCTAssertTrue(cleanCSS.contains("--bg: #ffffff"), "Clean printing should use white background")
|
||||
XCTAssertTrue(cleanCSS.contains("--text: #24292e"), "Clean printing should use dark text")
|
||||
|
||||
// Verify Active Theme option
|
||||
let activeCSS = manager.generateCSS(for: manager.activeTheme, zoomScale: 1.0)
|
||||
XCTAssertFalse(activeCSS.isEmpty, "Active theme CSS should generate non-empty CSS")
|
||||
|
||||
// Verify Plain HTML option
|
||||
let plainScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "",
|
||||
stripBackgrounds: true,
|
||||
stripImages: false,
|
||||
stripWidth: true,
|
||||
printBorders: false,
|
||||
pagePadding: "0px"
|
||||
)
|
||||
XCTAssertTrue(plainScript.contains("document.getElementById('theme-css').textContent = ''"))
|
||||
}
|
||||
|
||||
func testPrintZoomResolver() {
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Compact (75%)", screenZoom: 1.5), 0.75)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Small (85%)", screenZoom: 1.5), 0.85)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Standard (100%)", screenZoom: 1.5), 1.0)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Large (115%)", screenZoom: 1.5), 1.15)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Match Screen", screenZoom: 1.5), 1.5)
|
||||
XCTAssertEqual(PrintZoomResolver.zoomScale(for: "Unknown", screenZoom: 1.5), 1.0)
|
||||
}
|
||||
|
||||
func testCleanPrintingThemeUsesPrintProportionalFontSizes() {
|
||||
let sizes = Theme.cleanPrinting.sizes
|
||||
XCTAssertLessThanOrEqual(sizes.baseFontSize, 12.0)
|
||||
XCTAssertLessThanOrEqual(sizes.h1Size, 22.0)
|
||||
XCTAssertLessThanOrEqual(sizes.h2Size, 18.0)
|
||||
XCTAssertLessThanOrEqual(sizes.codeFontSize, 11.0)
|
||||
}
|
||||
|
||||
func testSettingsViewIncludesExpandedFontScalingOptions() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Compact (75%)").tag("Compact (75%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Small (85%)").tag("Small (85%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Standard (100%)").tag("Standard (100%)")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Text("Large (115%)").tag("Large (115%)")"#))
|
||||
}
|
||||
|
||||
// 4. Functional test for PrintCoordinator script building and memory cycles
|
||||
@MainActor
|
||||
func testPrintCoordinatorMemoryManagementAndRestore() {
|
||||
class MockWebView: WKWebView {
|
||||
var lastEvaluatedJavaScript: String?
|
||||
|
||||
override func evaluateJavaScript(_ javaScriptString: String, completionHandler: ((Any?, Error?) -> Void)? = nil) {
|
||||
self.lastEvaluatedJavaScript = javaScriptString
|
||||
completionHandler?(nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
let webView = MockWebView()
|
||||
var coordinator: PrintCoordinator? = PrintCoordinator(
|
||||
webView: webView,
|
||||
restoreCSS: "body { background: 'white'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
weak var weakCoordinator = coordinator
|
||||
XCTAssertNotNil(weakCoordinator)
|
||||
|
||||
// Pass retained pointer simulating print runModal contextInfo
|
||||
let pointer = Unmanaged.passRetained(coordinator!).toOpaque()
|
||||
|
||||
// Release our strong local reference (ref count is now held only by the unmanaged pointer)
|
||||
coordinator = nil
|
||||
XCTAssertNotNil(weakCoordinator)
|
||||
|
||||
// Simulate AppKit delegate execution finishing
|
||||
let dummyOperation = NSPrintOperation()
|
||||
weakCoordinator?.printOperationDidRun(dummyOperation, success: true, contextInfo: pointer)
|
||||
|
||||
// Assert the unmanaged pointer is released and coordinator is deallocated
|
||||
XCTAssertNil(weakCoordinator)
|
||||
|
||||
// Assert Javascript script execution occurred and escaped CSS correctly
|
||||
XCTAssertNotNil(webView.lastEvaluatedJavaScript)
|
||||
let js = webView.lastEvaluatedJavaScript!
|
||||
XCTAssertTrue(js.contains("document.getElementById('theme-css').textContent = 'body { background: \\'white\\'; }';"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-no-images');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-full-width');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.classList.remove('print-borders');"))
|
||||
XCTAssertTrue(js.contains("document.documentElement.style.removeProperty('--print-padding');"))
|
||||
}
|
||||
|
||||
func testCleanPrintingThemeIsBuiltInAndIndependent() {
|
||||
let theme = Theme.cleanPrinting
|
||||
XCTAssertEqual(theme.name, "Clean Printing")
|
||||
XCTAssertTrue(theme.isBuiltIn)
|
||||
XCTAssertEqual(theme.colors.background, "#ffffff")
|
||||
XCTAssertEqual(theme.colors.text, "#24292e")
|
||||
}
|
||||
|
||||
// 5. Test that template print query stylesheet contains the print-borders rules
|
||||
func testTemplatePrintStylesForBorders() throws {
|
||||
let template = try loadTemplate()
|
||||
let printBlock = try printMediaQueryBlock(in: template)
|
||||
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders pre"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders .frontmatter"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders blockquote"))
|
||||
XCTAssertTrue(printBlock.contains("html.print-borders code"))
|
||||
}
|
||||
|
||||
// 6. Test that PrintScriptGenerator outputs correct prepare and restore scripts
|
||||
func testPrintScriptGeneratorScriptOutputs() {
|
||||
let prepareScript = PrintScriptGenerator.prepareScript(
|
||||
escapedCSS: "body { background: 'white'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
XCTAssertTrue(prepareScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'white\'; }';"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-backgrounds');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-no-images');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-full-width');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.classList.add('print-borders');"))
|
||||
XCTAssertTrue(prepareScript.contains("document.documentElement.style.setProperty('--print-padding', '48px');"))
|
||||
|
||||
let restoreScript = PrintScriptGenerator.restoreScript(
|
||||
escapedCSS: "body { background: 'dark'; }",
|
||||
stripBackgrounds: true,
|
||||
stripImages: true,
|
||||
stripWidth: true,
|
||||
printBorders: true,
|
||||
pagePadding: "48px"
|
||||
)
|
||||
|
||||
XCTAssertTrue(restoreScript.contains("document.getElementById('theme-css').textContent = 'body { background: \'dark\'; }';"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-backgrounds');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-no-images');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-full-width');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.classList.remove('print-borders');"))
|
||||
XCTAssertTrue(restoreScript.contains("document.documentElement.style.removeProperty('--print-padding');"))
|
||||
}
|
||||
|
||||
// Helper function to read source files
|
||||
private func loadSourceFile(_ relativePath: String) throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let sourceURL = repoRoot.appendingPathComponent(relativePath)
|
||||
return try String(contentsOf: sourceURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
// Helper function to read HTML template
|
||||
private func loadTemplate() throws -> String {
|
||||
let testFileURL = URL(fileURLWithPath: #filePath)
|
||||
let repoRoot = testFileURL
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
let templateURL = repoRoot.appendingPathComponent("markdownViewr/Resources/template.html")
|
||||
return try String(contentsOf: templateURL, encoding: .utf8)
|
||||
}
|
||||
|
||||
// Helper function to extract @media print block
|
||||
private func printMediaQueryBlock(in template: String) throws -> String {
|
||||
let start = try XCTUnwrap(template.range(of: "@media print {"))
|
||||
let remainder = template[start.upperBound...]
|
||||
|
||||
var bracketCount = 1
|
||||
var index = remainder.startIndex
|
||||
|
||||
while bracketCount > 0 && index < remainder.endIndex {
|
||||
let char = remainder[index]
|
||||
if char == "{" {
|
||||
bracketCount += 1
|
||||
} else if char == "}" {
|
||||
bracketCount -= 1
|
||||
}
|
||||
if bracketCount > 0 {
|
||||
index = remainder.index(after: index)
|
||||
}
|
||||
}
|
||||
|
||||
return String(remainder[..<index])
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,48 @@ final class TemplateScrollbarTests: XCTestCase {
|
||||
XCTAssertFalse(template.contains("activeLink.scrollIntoView"))
|
||||
}
|
||||
|
||||
func testCommandArrowKeysScrollDocumentToTopAndBottom() throws {
|
||||
let template = try loadTemplate()
|
||||
|
||||
XCTAssertTrue(template.contains("function _handleCommandArrowScroll(e)"))
|
||||
XCTAssertTrue(template.contains("if (!e.metaKey || e.shiftKey || e.ctrlKey || e.altKey) return false"))
|
||||
XCTAssertTrue(template.contains("if (e.key === 'ArrowUp')"))
|
||||
XCTAssertTrue(template.contains("_contentScroller().scrollTo({ top: 0, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("if (e.key === 'ArrowDown')"))
|
||||
XCTAssertTrue(template.contains("content.scrollTo({ top: content.scrollHeight, behavior: 'smooth' })"))
|
||||
XCTAssertTrue(template.contains("if (_handleCommandArrowScroll(e)) return"))
|
||||
}
|
||||
|
||||
func testTOCHighlightFollowsHoveredRenderedSection() throws {
|
||||
let template = try loadTemplate()
|
||||
let updateStart = try XCTUnwrap(template.range(of: "function updateContent(html)"))
|
||||
let updateRemainder = template[updateStart.lowerBound...]
|
||||
let updateEnd = try XCTUnwrap(updateRemainder.range(of: "var _findPairs"))
|
||||
let updateContentFunction = String(updateRemainder[..<updateEnd.lowerBound])
|
||||
let activeTOCRule = try cssRule("#toc a.active", in: template)
|
||||
|
||||
XCTAssertTrue(template.contains("var _tocHoverHeadingId = null"))
|
||||
XCTAssertTrue(template.contains("function setupTOCHoverHighlight()"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('mousemove'"))
|
||||
XCTAssertTrue(template.contains("content.addEventListener('mouseleave'"))
|
||||
XCTAssertTrue(template.contains("raw.addEventListener('mousemove'"))
|
||||
XCTAssertTrue(template.contains("raw.addEventListener('mouseleave'"))
|
||||
XCTAssertTrue(template.contains("function _headingForHoverPosition(clientY)"))
|
||||
XCTAssertTrue(template.contains("_headingForHoverPosition(event.clientY)"))
|
||||
XCTAssertTrue(template.contains("function _headingIdForRawHoverPosition(clientY)"))
|
||||
XCTAssertTrue(template.contains("_headingIdForRawHoverPosition(event.clientY)"))
|
||||
XCTAssertTrue(template.contains("heading.getBoundingClientRect().top <= clientY"))
|
||||
XCTAssertTrue(template.contains("function _tocHeadingIdForHover(heading)"))
|
||||
XCTAssertTrue(template.contains("_applyTOCHoverHeadingId(_tocHeadingIdForHover(heading))"))
|
||||
XCTAssertTrue(template.contains("function _tocLinkForHeadingId(headingId)"))
|
||||
XCTAssertTrue(template.contains("links[i].dataset.headingId === headingId"))
|
||||
XCTAssertTrue(template.contains("if (_tocHoverHeadingId) return"))
|
||||
XCTAssertTrue(template.contains("highlightTOCHeading(_tocHoverHeadingId, false)"))
|
||||
XCTAssertTrue(template.contains("highlightActiveTOC()"))
|
||||
XCTAssertTrue(updateContentFunction.contains("_tocHoverHeadingId = null"))
|
||||
XCTAssertFalse(activeTOCRule.contains("font-weight"))
|
||||
}
|
||||
|
||||
func testCSSHelpDocumentsScrollbarSelectors() throws {
|
||||
let cssHelp = try loadSourceFile("markdownViewr/CSSHelpView.swift")
|
||||
let appHelp = try loadSourceFile("markdownViewr/HelpView.swift")
|
||||
@@ -272,9 +314,25 @@ final class TemplateScrollbarTests: XCTestCase {
|
||||
|
||||
func testDocumentWindowFrameConfigurationSkipsTabbedWindows() {
|
||||
XCTAssertTrue(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false))
|
||||
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: false, hasTabbedHistory: true))
|
||||
XCTAssertFalse(DocumentWindowConfigurator.shouldApplyDocumentFrame(isTabbedWindow: true))
|
||||
}
|
||||
|
||||
func testDocumentWindowFrameAutosaveSkipsTabbedWindows() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains("let isTabbedWindow = Self.isTabbedWindow(window)"))
|
||||
XCTAssertTrue(appSource.contains("window.isRestorable = false"))
|
||||
XCTAssertTrue(appSource.contains("if isTabbedWindow {\n Self.markTabGroupHasTabbedHistory(window)\n context.coordinator.stopSavingFrame(for: window)\n return\n }"))
|
||||
XCTAssertTrue(appSource.contains("Self.markTabGroupHasTabbedHistory(window)"))
|
||||
XCTAssertTrue(appSource.contains("tabGroupWindows.forEach {\n windowsWithTabbedHistory.add($0)\n $0.isRestorable = false\n $0.setFrameAutosaveName(\"\")\n }"))
|
||||
XCTAssertTrue(appSource.contains("let hasTabbedHistory = Self.windowHasTabbedHistory(window)"))
|
||||
XCTAssertTrue(appSource.contains("if !hasTabbedHistory {\n window.setFrameAutosaveName(autosaveName)\n }\n context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("private static let windowsWithTabbedHistory = NSHashTable<NSWindow>.weakObjects()"))
|
||||
XCTAssertTrue(appSource.contains("func stopSavingFrame(for window: NSWindow)"))
|
||||
XCTAssertFalse(appSource.contains("func consumeRecentlyTabbedWindow(_ window: NSWindow) -> Bool"))
|
||||
}
|
||||
|
||||
func testInitialSourceWidthUsesHalfAvailableWindowWidth() {
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: false, tocWidth: 300),
|
||||
@@ -411,7 +469,7 @@ final class TemplateScrollbarTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(contentSource.contains(".space"))
|
||||
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
|
||||
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .space]"))
|
||||
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .markdownSource, .zoom, .theme, .externalEditor, .printDocument, .space]"))
|
||||
XCTAssertTrue(contentSource.contains("return [.toc, .tocDepth, .space, .markdownSource, .zoom, .theme, .externalEditor]"))
|
||||
XCTAssertFalse(contentSource.contains("case .fixedSpace:"))
|
||||
XCTAssertFalse(contentSource.contains("makeFixedSpaceItem"))
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ targetTemplates:
|
||||
INFOPLIST_FILE: markdownViewr/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
|
||||
PRODUCT_NAME: markdownViewr
|
||||
MARKETING_VERSION: "1.4.0"
|
||||
CURRENT_PROJECT_VERSION: "4"
|
||||
MARKETING_VERSION: "1.5.0"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
|
||||
|
||||
Reference in new issue
Block a user