Compare 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 484d9af0ea Disable frame autosave for tabbed document windows 2026-07-22 08:26:57 -05:00
Darin Kelkhoff 6d9b5a32f5 Improve TOC hover and document navigation 2026-07-17 09:59:20 -05:00
14 changed files with 611 additions and 39 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"
+24 -2
View File
@@ -272,11 +272,13 @@ struct ContentView: View {
} else if printTheme == "Plain HTML" {
themeToPrint = nil
} else {
themeToPrint = themeManager.themes.first { $0.name == printTheme } ?? Theme.cleanPrinting
themeToPrint = themeManager.allThemes.first { $0.name == printTheme }
?? themeManager.themes.first { $0.name == printTheme }
?? Theme.cleanPrinting
}
// Determine zoom scale based on print settings
let zoom = printZoom == "Match Screen" ? zoomScale : 1.0
let zoom = PrintZoomResolver.zoomScale(for: printZoom, screenZoom: zoomScale)
let printCSS = themeToPrint != nil ? themeManager.generateCSS(for: themeToPrint!, zoomScale: zoom) : ""
let escapedPrintCSS = printCSS.escapedForJavaScriptLiteral()
@@ -1191,6 +1193,7 @@ struct PrintScriptGenerator {
\(fullWidthJS)
\(bordersJS)
\(paddingJS)
void document.body.offsetHeight;
"""
}
@@ -1226,3 +1229,22 @@ fileprivate extension String {
.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)
+36 -4
View File
@@ -177,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 {
@@ -209,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)
}
@@ -227,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?
@@ -268,6 +294,12 @@ struct DocumentWindowConfigurator: NSViewRepresentable {
window.saveFrame(usingName: autosaveName)
}
func stopSavingFrame(for window: NSWindow) {
removeObservers()
configuredWindow = nil
configuredAutosaveName = nil
}
deinit {
removeObservers()
}
+195 -8
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; }
@@ -421,6 +421,40 @@ mark {
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 {
@@ -454,22 +488,25 @@ mark {
html.print-no-backgrounds, html.print-no-backgrounds body {
background-color: #ffffff !important;
color: #000000 !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;
/* Avoid light text rendering on white backgrounds */
color: #000000 !important;
}
/* Maintain link readability in ink-saving mode */
html.print-no-backgrounds a {
color: #0969da !important;
text-decoration: underline !important;
}
@@ -571,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;
@@ -781,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 = [];
@@ -1139,6 +1179,7 @@ function setupTOCScrollHighlight() {
var timeout;
var content = _contentScroller();
content.addEventListener('scroll', function() {
if (_tocHoverHeadingId) return;
clearTimeout(timeout);
timeout = setTimeout(highlightActiveTOC, 50);
});
@@ -1167,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;
@@ -1185,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();
@@ -1250,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;
+7 -3
View File
@@ -581,17 +581,21 @@ struct PrintingSettingsView: View {
GroupBox(label: Text("Zoom & Scaling")) {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("Font Zoom")
Text("Font Scaling")
Spacer()
Picker("Font Zoom", selection: $printZoom) {
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 whether the printed document inherits the screen zoom scaling.")
Text("Select the scale for printed document fonts, or match current screen zoom.")
.font(.caption)
.foregroundStyle(.secondary)
+11 -1
View File
@@ -194,7 +194,17 @@ extension Theme {
highlightText: "#24292e"
),
fonts: ThemeFonts(body: "System", heading: "System", code: "SF Mono"),
sizes: ThemeSizes(),
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
)
+135 -5
View File
@@ -142,14 +142,12 @@ final class PrintingTests: XCTestCase {
XCTAssertTrue(printBlock.contains("-webkit-print-color-adjust: exact !important"))
XCTAssertTrue(printBlock.contains("print-color-adjust: exact !important"))
// Verify print background toggling override style exists
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds *"))
// 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"))
XCTAssertTrue(printBlock.contains("background: transparent !important"))
XCTAssertTrue(printBlock.contains("color: #000000 !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 a"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds hr"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds blockquote"))
XCTAssertTrue(printBlock.contains("html.print-no-backgrounds h1"))
@@ -205,6 +203,138 @@ final class PrintingTests: XCTestCase {
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() {
@@ -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),
+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