Compare commits

Author SHA1 Message Date
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
7 changed files with 365 additions and 24 deletions

No files matched your search

+9 -9
View File
@@ -5,6 +5,14 @@
<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: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 +31,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>
</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"
+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()
}
+154 -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; }
@@ -571,6 +571,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 +782,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 +1142,7 @@ function setupTOCScrollHighlight() {
var timeout;
var content = _contentScroller();
content.addEventListener('scroll', function() {
if (_tocHoverHeadingId) return;
clearTimeout(timeout);
timeout = setTimeout(highlightActiveTOC, 50);
});
@@ -1167,7 +1171,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 +1218,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 +1383,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;
@@ -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