Compare commits

23 Commits
Author SHA1 Message Date
james.maes 466a1e655c Merge pull request 'ci: shared ci-workflows macOS workflow; fix the Xcode 27 Document ambiguity (blocked: runner has no GUI session)' (#3) from ci/shared-macos into main
macos / macos (push) Successful in 14s
build / macos (push) Successful in 14s
2026-11-08 23:28:56 +00:00
James MaesandClaude Opus 5.5 be68c9f1bb ci: build only on CI; run the app-hosted tests locally
macos / macos (push) Successful in 16s
build / macos (push) Successful in 16s
The grogu runner has no GUI session, so xcodebuild test cannot reach
testmanagerd. CI compiles the app and the test bundle unsigned
(build-for-testing); the README documents running just test locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 17:24:40 -06:00
James MaesandClaude Opus 5.5 77b9274434 ci: pin the shared workflow to v1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:46:19 -06:00
James MaesandClaude Opus 5.5 ab3731e0df fix: qualify Markdown.Document for the Xcode 27 SDK
macos / macos (push) Failing after 14s
build / macos (push) Failing after 14s
SwiftUI now declares a Document protocol, so the unqualified name in the
plain-text visitor is ambiguous and the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:37:16 -06:00
James MaesandClaude Opus 5.5 4536db3f09 ci: build on the shared ci-workflows macOS workflow
macos / macos (push) Failing after 16s
build / macos (push) Failing after 16s
Runs the repository's build (and tests where it has them) on the KOF macOS
host runner through ci-workflows macos.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:34:06 -06:00
james.maes 2bfdf91f77 Merge pull request #1 from KofTwentyTwo/docs/fork-notice
docs: mark this repository as a contribution fork of the upstream app
2026-10-13 06:26:49 -05:00
james.maesandClaude Opus 5.5 6e25ec7963 docs: mark this repository as a contribution fork of the upstream app
The README was the upstream README word for word, so it read as if
KofTwentyTwo shipped the macOS app. Every download, cask and update link
points at darinkelkhoff/markdownViewr, and this fork publishes no
releases. Say so, and point to the upstream printing PRs (#2 merged,
#3 open) and to the Windows port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 06:23:23 -05:00
james.maes 1ab16bf4ed feat(printing): add print font scaling options and document-proportional clean print theme sizing 2026-07-24 08:28:58 -05:00
james.maes 0d1133aafd test(printing): add unit test validating every theme option generates non-empty CSS and script injection 2026-07-24 08:26:49 -05:00
james.maes fbb313ccb9 test(printing): expand test assertions for all heading sizes, colors, blockquotes, and highlights 2026-07-24 08:26:15 -05:00
james.maes 53a6ae7896 fix(printing): enforce theme fonts, sizes, and colors in print media queries 2026-07-24 08:23:49 -05:00
Darin Kelkhoff 76ed62618c release: add 1.5.0 release notes link to appcast 2026-07-23 08:35:09 -05:00
Darin Kelkhoff cb29d64a1d release: update appcast for v1.5.0 2026-07-23 08:33:56 -05:00
Darin Kelkhoff cde9351f11 Prepare release 1.5.0 2026-07-23 08:31:48 -05:00
Darin Kelkhoff ad2cc7e55f Merge pull request #2 from KofTwentyTwo/feature/mac-printing
feat: Add native macOS printing and custom printing configuration preferences
2026-07-23 08:22:17 -05:00
Darin Kelkhoff 05d65cf0c7 Clean up printing PR whitespace 2026-07-23 08:13:50 -05:00
james.maes ff7b942d46 fix(printing): update test assertions for clean print theme and space item 2026-07-22 12:05:47 -05:00
james.maes 01ff200f8d merge: incorporate native toolbar space item from fix/toolbar-spacer 2026-07-22 12:05:02 -05:00
james.maes c9e8867ec3 fix(printing): use built-in Theme.cleanPrinting for clean print option 2026-07-22 12:04:37 -05:00
Darin Kelkhoff 2c2f153299 Polish printing toolbar integration 2026-07-22 10:00:18 -05:00
Darin Kelkhoff 484d9af0ea Disable frame autosave for tabbed document windows 2026-07-22 08:26:57 -05:00
james.maes 87f8318f4d feat: Add native macOS printing and custom printing configuration preferences 2026-07-21 16:21:56 -05:00
Darin Kelkhoff 6d9b5a32f5 Improve TOC hover and document navigation 2026-07-17 09:59:20 -05:00
14 changed files with 1496 additions and 27 deletions

No files matched your search

+16
View File
@@ -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
+11
View File
@@ -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.
[![Download](https://img.shields.io/badge/Download-macOS-blue?style=flat-square&logo=apple)](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
View File
@@ -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>
+90
View File
@@ -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>
+16 -5
View File
@@ -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"
+260 -2
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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)
+44 -4
View File
@@ -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()
}
+322 -4
View File
@@ -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;
+143
View File
@@ -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?
+38
View File
@@ -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
)
}
+485
View File
@@ -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
View File
@@ -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