Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb29d64a1d | ||
|
|
cde9351f11 | ||
|
|
ad2cc7e55f | ||
|
|
05d65cf0c7 | ||
|
|
ff7b942d46 | ||
|
|
01ff200f8d | ||
|
|
c9e8867ec3 | ||
|
|
2c2f153299 | ||
|
|
f6f4ac723c | ||
|
|
484d9af0ea | ||
|
|
87f8318f4d | ||
|
|
4199c8b375 | ||
|
|
6d9b5a32f5 | ||
|
|
baac7e71a8 | ||
|
|
1abc51eb25 | ||
|
|
91985c3fbc | ||
|
|
39a08ae42e | ||
|
|
8373722174 | ||
|
|
e06facc915 | ||
|
|
71d71df7dd | ||
|
|
842d6bf025 | ||
|
|
28458b0892 | ||
|
|
ad76a0c8e1 |
No files matched your search
+18
-17
@@ -5,6 +5,23 @@
|
||||
<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>
|
||||
<sparkle:version>10400</sparkle:version>
|
||||
<sparkle:shortVersionString>1.4.0</sparkle:shortVersionString>
|
||||
<sparkle:releaseNotesLink>https://darinkelkhoff.github.io/markdownViewr/releases/1.4.0.html</sparkle:releaseNotesLink>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.4.0/markdownViewr-1.4.0.zip" length="4385892" type="application/octet-stream" sparkle:edSignature="7OtkBh/Qd6/BB5MYr38LVGJA3C24+hzFfd4OOe88gim2LEKShRoMkz8EXuuCCIhcVbDIDrtcU6XzckshCKKJBg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>1.3.0</title>
|
||||
<pubDate>Fri, 19 Jun 2026 14:47:14 -0500</pubDate>
|
||||
@@ -14,21 +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>
|
||||
<item>
|
||||
<title>1.1.6</title>
|
||||
<pubDate>Mon, 08 Jun 2026 12:01:59 -0500</pubDate>
|
||||
<sparkle:version>10106</sparkle:version>
|
||||
<sparkle:shortVersionString>1.1.6</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>13.0</sparkle:minimumSystemVersion>
|
||||
<enclosure url="https://github.com/darinkelkhoff/markdownViewr/releases/download/v1.1.6/markdownViewr-1.1.6.zip" length="4202331" type="application/octet-stream" sparkle:edSignature="PJHorFzsVMKacxkjrVMNfRYoUfoHrBvy0okZ7NO7+0fFSOqFEi50D0dMlog8DlCvOxeXEfkMXQoPjmugDArcCg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
</rss>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>markdownViewr 1.4.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.4.0</h1>
|
||||
<p class="date">Released June 26, 2026</p>
|
||||
|
||||
<h2>Native macOS toolbar customization</h2>
|
||||
<ul>
|
||||
<li>Added native macOS toolbar customization for document windows.</li>
|
||||
</ul>
|
||||
|
||||
<h2>View settings now apply per window</h2>
|
||||
<ul>
|
||||
<li>TOC, TOC depth, Markdown Source, zoom, and active theme are now per-window settings.</li>
|
||||
<li>Added Settings options to define default settings for new windows.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Menu and shortcut improvements</h2>
|
||||
<ul>
|
||||
<li>Cleaned up the File menu by removing New, Save, and Revert, and adding Open in External Editor.</li>
|
||||
<li>Added View menu commands for TOC, Markdown Source, and TOC depth.</li>
|
||||
<li>Added <code>Cmd-T</code> to toggle TOC, <code>Cmd-U</code> to toggle Markdown Source, and <code>Cmd-E</code> to open in the external editor.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Window and settings polish</h2>
|
||||
<ul>
|
||||
<li>Improved default window sizing and side pane widths.</li>
|
||||
<li>Added per-file window size restoration.</li>
|
||||
<li>Added the ability to reorder external editors.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,17 @@
|
||||
# Think different
|
||||
|
||||
## Here's to the crazy ones.
|
||||
|
||||
1. The misfits
|
||||
2. The rebels
|
||||
3. The troublemakers
|
||||
|
||||
The round pegs in the square holes. The ones who see things differently. They're not fond of rules.
|
||||
And they have no respect for the status quo. You can quote them, disagree with them, glorify or vilify them. About the only thing you can't do is ignore them. Because they change things.
|
||||
|
||||
***They push the human race forward***.
|
||||
|
||||
And while some may see them as the crazy ones, we see genius. Because the people who are crazy enough to think they can change the world, are the ones who do.
|
||||
|
||||
[Think different](http://www.apple.com)
|
||||
|
||||
@@ -11,11 +11,11 @@ build:
|
||||
|
||||
# Kill, build, and launch the app
|
||||
run: kill build
|
||||
@open "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
|
||||
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app"
|
||||
|
||||
# Open a specific markdown file
|
||||
open file: build
|
||||
@open -a "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
|
||||
@open -n "$(xcodebuild -project markdownViewr.xcodeproj -scheme markdownViewr -showBuildSettings 2>/dev/null | grep ' BUILT_PRODUCTS_DIR' | awk '{print $3}')/markdownViewr.app" "{{file}}"
|
||||
|
||||
# Run the test suite
|
||||
test:
|
||||
@@ -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"
|
||||
|
||||
+917
-128
File diff suppressed because it is too large.
Load diff
@@ -23,6 +23,24 @@ struct EditorConfig: Codable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExternalEditorMenuState: Equatable {
|
||||
case disabled
|
||||
case single(EditorConfig, title: String)
|
||||
case submenu([EditorConfig])
|
||||
|
||||
init(editors: [EditorConfig]) {
|
||||
switch editors.count {
|
||||
case 0:
|
||||
self = .disabled
|
||||
case 1:
|
||||
let editor = editors[0]
|
||||
self = .single(editor, title: "Open in \(editor.name)")
|
||||
default:
|
||||
self = .submenu(editors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EditorManager: ObservableObject {
|
||||
@Published var editors: [EditorConfig] {
|
||||
didSet {
|
||||
@@ -51,6 +69,21 @@ class EditorManager: ObservableObject {
|
||||
editors.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
func moveEditor(id: UUID, by offset: Int) {
|
||||
guard let source = editors.firstIndex(where: { $0.id == id }) else { return }
|
||||
let destination = source + offset
|
||||
guard editors.indices.contains(destination) else { return }
|
||||
|
||||
var reorderedEditors = editors
|
||||
reorderedEditors.swapAt(source, destination)
|
||||
editors = reorderedEditors
|
||||
}
|
||||
|
||||
func setOpensFolder(_ opensFolder: Bool, for id: UUID) {
|
||||
guard let index = editors.firstIndex(where: { $0.id == id }) else { return }
|
||||
editors[index].opensFolder = opensFolder
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(editors) {
|
||||
UserDefaults.standard.set(data, forKey: "configuredEditors")
|
||||
@@ -70,6 +103,8 @@ class EditorManager: ObservableObject {
|
||||
: fileURL
|
||||
|
||||
let config = NSWorkspace.OpenConfiguration()
|
||||
config.activates = true
|
||||
config.allowsRunningApplicationSubstitution = false
|
||||
workspace.open([urlToOpen], withApplicationAt: appURL, configuration: config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
enum FileMenuPruner {
|
||||
private static let removedTitles: Set<String> = ["New", "Save", "Save...", "Revert To"]
|
||||
|
||||
static func removeEditingItems(from menu: NSMenu) {
|
||||
for item in menu.items.reversed() where shouldRemove(item) {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
static func removeEditingItems(from mainMenu: NSMenu?) {
|
||||
guard let fileMenu = fileMenu(in: mainMenu) else { return }
|
||||
removeEditingItems(from: fileMenu)
|
||||
}
|
||||
|
||||
static func fileMenu(in mainMenu: NSMenu?) -> NSMenu? {
|
||||
mainMenu?.item(withTitle: "File")?.submenu
|
||||
}
|
||||
|
||||
private static func shouldRemove(_ item: NSMenuItem) -> Bool {
|
||||
let title = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
|
||||
return removedTitles.contains(title) || title.hasPrefix("Revert To")
|
||||
}
|
||||
}
|
||||
|
||||
final class FileMenuPrunerApplicationDelegate: NSObject, NSApplicationDelegate {
|
||||
private var menuObserver: NSObjectProtocol?
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
installMenuObserver()
|
||||
DispatchQueue.main.async {
|
||||
self.configureFileMenu()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let menuObserver {
|
||||
NotificationCenter.default.removeObserver(menuObserver)
|
||||
}
|
||||
}
|
||||
|
||||
private func installMenuObserver() {
|
||||
guard menuObserver == nil else { return }
|
||||
menuObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSMenu.didBeginTrackingNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
guard let menu = notification.object as? NSMenu,
|
||||
menu === FileMenuPruner.fileMenu(in: NSApp.mainMenu)
|
||||
else { return }
|
||||
self.updateFileMenu(menu)
|
||||
}
|
||||
}
|
||||
|
||||
private func configureFileMenu() {
|
||||
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApp.mainMenu) else { return }
|
||||
updateFileMenu(fileMenu)
|
||||
}
|
||||
|
||||
private func updateFileMenu(_ fileMenu: NSMenu) {
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
ExternalEditorFileMenuController.shared.update(in: fileMenu)
|
||||
}
|
||||
}
|
||||
|
||||
final class ExternalEditorFileMenuController: NSObject, NSMenuItemValidation {
|
||||
static let shared = ExternalEditorFileMenuController()
|
||||
|
||||
var currentFileURLProvider: () -> URL? = {
|
||||
ExternalEditorFileMenuController.currentOpenDocumentFileURL()
|
||||
}
|
||||
|
||||
weak var editorManager: EditorManager? {
|
||||
didSet {
|
||||
editorSubscription = editorManager?.$editors.sink { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.updateMainMenu()
|
||||
}
|
||||
}
|
||||
updateMainMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private let dividerIdentifier = "markdownViewr.externalEditor.divider"
|
||||
private let itemIdentifier = "markdownViewr.externalEditor.item"
|
||||
private var editorSubscription: AnyCancellable?
|
||||
|
||||
private var dividerMenuIdentifier: NSUserInterfaceItemIdentifier {
|
||||
NSUserInterfaceItemIdentifier(dividerIdentifier)
|
||||
}
|
||||
|
||||
private var itemMenuIdentifier: NSUserInterfaceItemIdentifier {
|
||||
NSUserInterfaceItemIdentifier(itemIdentifier)
|
||||
}
|
||||
|
||||
static func currentOpenDocumentFileURL() -> URL? {
|
||||
let documentController = NSDocumentController.shared
|
||||
return currentOpenDocumentFileURL(in: [documentController.currentDocument] + documentController.documents)
|
||||
}
|
||||
|
||||
static func currentOpenDocumentFileURL(in documents: [NSDocument?]) -> URL? {
|
||||
for document in documents {
|
||||
guard let document else { continue }
|
||||
guard document.windowControllers.contains(where: { $0.window?.isVisible == true }) else { continue }
|
||||
if let fileURL = document.fileURL {
|
||||
return fileURL
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateMainMenu() {
|
||||
guard let fileMenu = FileMenuPruner.fileMenu(in: NSApplication.shared.mainMenu) else { return }
|
||||
update(in: fileMenu)
|
||||
}
|
||||
|
||||
func update(in menu: NSMenu) {
|
||||
removeExistingItems(from: menu)
|
||||
|
||||
let separator = NSMenuItem.separator()
|
||||
separator.identifier = dividerMenuIdentifier
|
||||
|
||||
let externalEditorItem = makeExternalEditorItem()
|
||||
externalEditorItem.identifier = itemMenuIdentifier
|
||||
|
||||
let insertionIndex = externalEditorInsertionIndex(in: menu)
|
||||
menu.insertItem(separator, at: insertionIndex)
|
||||
menu.insertItem(externalEditorItem, at: insertionIndex + 1)
|
||||
}
|
||||
|
||||
private func makeExternalEditorItem() -> NSMenuItem {
|
||||
let editors = editorManager?.editors ?? []
|
||||
let hasOpenFile = currentFileURLProvider() != nil
|
||||
|
||||
switch ExternalEditorMenuState(editors: editors) {
|
||||
case .disabled:
|
||||
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
|
||||
item.isEnabled = false
|
||||
return item
|
||||
|
||||
case let .single(editor, title):
|
||||
return makeEditorMenuItem(title: title, editor: editor, keyEquivalent: "e", isEnabled: hasOpenFile)
|
||||
|
||||
case let .submenu(editors):
|
||||
let item = NSMenuItem(title: "Open in External Editor", action: nil, keyEquivalent: "")
|
||||
let submenu = NSMenu(title: "Open in External Editor")
|
||||
for (index, editor) in editors.enumerated() {
|
||||
submenu.addItem(makeEditorMenuItem(
|
||||
title: editor.name,
|
||||
editor: editor,
|
||||
keyEquivalent: index == 0 ? "e" : "",
|
||||
isEnabled: hasOpenFile
|
||||
))
|
||||
}
|
||||
item.submenu = submenu
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
private func makeEditorMenuItem(
|
||||
title: String,
|
||||
editor: EditorConfig,
|
||||
keyEquivalent: String = "",
|
||||
isEnabled: Bool = true
|
||||
) -> NSMenuItem {
|
||||
let item = NSMenuItem(
|
||||
title: title,
|
||||
action: isEnabled ? #selector(openInExternalEditor(_:)) : nil,
|
||||
keyEquivalent: keyEquivalent
|
||||
)
|
||||
item.target = isEnabled ? self : nil
|
||||
item.representedObject = editor.id.uuidString
|
||||
item.isEnabled = isEnabled
|
||||
item.keyEquivalentModifierMask = [.command]
|
||||
return item
|
||||
}
|
||||
|
||||
@objc private func openInExternalEditor(_ sender: NSMenuItem) {
|
||||
guard let editorIDString = sender.representedObject as? String,
|
||||
let editorID = UUID(uuidString: editorIDString),
|
||||
let editor = editorManager?.editors.first(where: { $0.id == editorID }),
|
||||
let fileURL = currentFileURLProvider()
|
||||
else { return }
|
||||
|
||||
editorManager?.openFile(fileURL, with: editor)
|
||||
}
|
||||
|
||||
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
|
||||
guard menuItem.representedObject as? String != nil else { return true }
|
||||
return currentFileURLProvider() != nil
|
||||
}
|
||||
|
||||
private func removeExistingItems(from menu: NSMenu) {
|
||||
for item in menu.items.reversed() {
|
||||
if item.identifier == dividerMenuIdentifier || item.identifier == itemMenuIdentifier {
|
||||
menu.removeItem(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func externalEditorInsertionIndex(in menu: NSMenu) -> Int {
|
||||
if let openRecentIndex = menu.items.firstIndex(where: { item in
|
||||
let normalizedTitle = item.title.replacingOccurrences(of: "…", with: "...").trimmingCharacters(in: .whitespaces)
|
||||
return normalizedTitle == "Open Recent"
|
||||
}) {
|
||||
return openRecentIndex + 1
|
||||
}
|
||||
|
||||
return min(menu.items.count, 1)
|
||||
}
|
||||
}
|
||||
@@ -435,7 +435,7 @@ struct HelpView: View {
|
||||
|
||||
Enable **Opens folder instead of file** if the editor works better when opened \
|
||||
to the containing directory rather than the file itself — useful for \
|
||||
project-based editors like VS Code or Nova, terminal emulators like Terminal \
|
||||
project-based editors like VS Code, terminal emulators like Terminal \
|
||||
(`/System/Applications/Utilities/Terminal.app`), or Finder \
|
||||
(`/System/Library/CoreServices/Finder.app`).
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import Sparkle
|
||||
|
||||
@main
|
||||
struct MarkdownViewrApp: App {
|
||||
@NSApplicationDelegateAdaptor(FileMenuPrunerApplicationDelegate.self) private var appDelegate
|
||||
|
||||
#if !MAS_BUILD
|
||||
private let updaterController = SPUStandardUpdaterController(
|
||||
startingUpdater: true,
|
||||
@@ -13,7 +15,14 @@ struct MarkdownViewrApp: App {
|
||||
)
|
||||
#endif
|
||||
@StateObject private var themeManager = ThemeManager()
|
||||
@StateObject private var editorManager = EditorManager()
|
||||
@StateObject private var editorManager: EditorManager
|
||||
@FocusedValue(\.documentViewCommands) private var documentViewCommands
|
||||
|
||||
init() {
|
||||
let editorManager = EditorManager()
|
||||
_editorManager = StateObject(wrappedValue: editorManager)
|
||||
ExternalEditorFileMenuController.shared.editorManager = editorManager
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
DocumentGroup(viewing: MarkdownDocument.self) { file in
|
||||
@@ -25,7 +34,7 @@ struct MarkdownViewrApp: App {
|
||||
.environmentObject(editorManager)
|
||||
.background(DocumentWindowConfigurator(fileURL: file.fileURL))
|
||||
}
|
||||
.defaultSize(width: 700, height: 900)
|
||||
.defaultSize(width: 1100, height: 800)
|
||||
.commands {
|
||||
#if !MAS_BUILD
|
||||
CommandGroup(after: .appInfo) {
|
||||
@@ -46,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)
|
||||
@@ -70,54 +87,229 @@ struct MarkdownViewrApp: App {
|
||||
.keyboardShortcut("?", modifiers: .command)
|
||||
}
|
||||
|
||||
CommandGroup(replacing: .toolbar) {
|
||||
Button("Zoom In") {
|
||||
themeManager.zoomIn()
|
||||
}
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
|
||||
Button("Zoom Out") {
|
||||
themeManager.zoomOut()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
|
||||
Button("Actual Size") {
|
||||
themeManager.zoomReset()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
CommandGroup(before: .toolbar) {
|
||||
DocumentViewCommandItems()
|
||||
}
|
||||
|
||||
CommandMenu("Theme") {
|
||||
Button("Next Theme") {
|
||||
themeManager.cycleTheme(direction: 1)
|
||||
documentViewCommands?.nextTheme()
|
||||
}
|
||||
.keyboardShortcut(.downArrow, modifiers: [.command, .shift])
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Previous Theme") {
|
||||
themeManager.cycleTheme(direction: -1)
|
||||
documentViewCommands?.previousTheme()
|
||||
}
|
||||
.keyboardShortcut(.upArrow, modifiers: [.command, .shift])
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentViewCommandItems: View {
|
||||
@FocusedValue(\.documentViewCommands) private var documentViewCommands
|
||||
|
||||
var body: some View {
|
||||
Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))
|
||||
.keyboardShortcut("t", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Menu("Table of Contents Depth") {
|
||||
ForEach(1...6, id: \.self) { depth in
|
||||
Button {
|
||||
documentViewCommands?.setTocDepth(depth)
|
||||
} label: {
|
||||
if documentViewCommands?.tocDepth == depth {
|
||||
Label("H\(depth)", systemImage: "checkmark")
|
||||
} else {
|
||||
Text("H\(depth)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))
|
||||
.keyboardShortcut("u", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Zoom In") {
|
||||
documentViewCommands?.zoomIn()
|
||||
}
|
||||
.keyboardShortcut("+", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Zoom Out") {
|
||||
documentViewCommands?.zoomOut()
|
||||
}
|
||||
.keyboardShortcut("-", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
|
||||
Button("Actual Size") {
|
||||
documentViewCommands?.zoomReset()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: .command)
|
||||
.disabled(documentViewCommands == nil)
|
||||
}
|
||||
}
|
||||
|
||||
struct DocumentWindowConfigurator: NSViewRepresentable {
|
||||
let fileURL: URL?
|
||||
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
|
||||
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
|
||||
|
||||
static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize {
|
||||
let width: CGFloat
|
||||
switch (tocVisible, rawVisible) {
|
||||
case (true, true):
|
||||
width = 1500
|
||||
case (false, true):
|
||||
width = 1350
|
||||
case (true, false):
|
||||
width = 1250
|
||||
case (false, false):
|
||||
width = 1100
|
||||
}
|
||||
return NSSize(width: width, height: 800)
|
||||
}
|
||||
|
||||
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 {
|
||||
var allowedCharacters = CharacterSet.alphanumerics
|
||||
allowedCharacters.insert(charactersIn: "-_.")
|
||||
let stablePath = fileURL.standardizedFileURL.path
|
||||
let encodedPath = stablePath.addingPercentEncoding(withAllowedCharacters: allowedCharacters) ?? stablePath
|
||||
return "doc-\(encodedPath)"
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
DispatchQueue.main.async {
|
||||
guard let window = view.window, let fileURL else { return }
|
||||
let autosaveName = "doc-\(fileURL.path.hashValue)"
|
||||
if !window.setFrameAutosaveName(autosaveName) {
|
||||
window.setFrameUsingName(autosaveName)
|
||||
}
|
||||
configureWindow(for: view, context: context)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
DispatchQueue.main.async {
|
||||
configureWindow(for: nsView, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
if !hasTabbedHistory {
|
||||
window.setFrameAutosaveName(autosaveName)
|
||||
}
|
||||
context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)
|
||||
}
|
||||
|
||||
private static func isTabbedWindow(_ window: NSWindow) -> Bool {
|
||||
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?
|
||||
private var observerTokens: [NSObjectProtocol] = []
|
||||
|
||||
func needsConfiguration(for window: NSWindow, autosaveName: String) -> Bool {
|
||||
configuredWindow !== window || configuredAutosaveName != autosaveName
|
||||
}
|
||||
|
||||
func configureSavingFrame(for window: NSWindow, autosaveName: String) {
|
||||
removeObservers()
|
||||
configuredWindow = window
|
||||
configuredAutosaveName = autosaveName
|
||||
|
||||
let center = NotificationCenter.default
|
||||
let saveFrame: (Notification) -> Void = { [weak window] _ in
|
||||
window?.saveFrame(usingName: autosaveName)
|
||||
}
|
||||
observerTokens = [
|
||||
center.addObserver(
|
||||
forName: NSWindow.willCloseNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
),
|
||||
center.addObserver(
|
||||
forName: NSWindow.didResizeNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
),
|
||||
center.addObserver(
|
||||
forName: NSWindow.didMoveNotification,
|
||||
object: window,
|
||||
queue: .main,
|
||||
using: saveFrame
|
||||
)
|
||||
]
|
||||
window.saveFrame(usingName: autosaveName)
|
||||
}
|
||||
|
||||
func stopSavingFrame(for window: NSWindow) {
|
||||
removeObservers()
|
||||
configuredWindow = nil
|
||||
configuredAutosaveName = nil
|
||||
}
|
||||
|
||||
deinit {
|
||||
removeObservers()
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
let center = NotificationCenter.default
|
||||
observerTokens.forEach { center.removeObserver($0) }
|
||||
observerTokens = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HelpWindowController: NSObject, NSWindowDelegate {
|
||||
@@ -170,10 +362,10 @@ class SettingsWindowController: NSObject, NSWindowDelegate {
|
||||
.environmentObject(editorManager)
|
||||
|
||||
let hostingView = NSHostingView(rootView: settingsView)
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)
|
||||
hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 600),
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 720),
|
||||
styleMask: [.titled, .closable, .resizable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
|
||||
@@ -62,6 +62,11 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
injectCSS(in: webView)
|
||||
}
|
||||
|
||||
if !context.coordinator.isPageLoaded {
|
||||
queueInitialState(context: context)
|
||||
return
|
||||
}
|
||||
|
||||
if context.coordinator.lastTocVisible != tocVisible {
|
||||
context.coordinator.lastTocVisible = tocVisible
|
||||
webView.evaluateJavaScript("setTOCVisible(\(tocVisible))") { _, _ in }
|
||||
@@ -116,6 +121,7 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
|
||||
context.coordinator.lastHTML = html
|
||||
context.coordinator.lastCSS = themeCSS
|
||||
context.coordinator.isPageLoaded = false
|
||||
#if MAS_BUILD
|
||||
// Images are inlined as data: URLs; a base href to the (sandboxed) folder is
|
||||
// both unreadable and unnecessary.
|
||||
@@ -147,7 +153,11 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
webView.loadHTMLString(template, baseURL: nil)
|
||||
}
|
||||
|
||||
// Queue TOC state to apply after page loads
|
||||
// Queue document state to apply after page loads.
|
||||
queueInitialState(context: context)
|
||||
}
|
||||
|
||||
private func queueInitialState(context: Context) {
|
||||
context.coordinator.pendingTocVisible = tocVisible
|
||||
context.coordinator.pendingTocDepth = tocDepth
|
||||
context.coordinator.pendingTocWidth = tocWidth
|
||||
@@ -195,6 +205,7 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
var lastRawSource: String = ""
|
||||
var lastRawVisible: Bool = false
|
||||
var lastRawWidth: Double = 400
|
||||
var isPageLoaded = false
|
||||
var pendingTocVisible: Bool?
|
||||
var pendingTocDepth: Int?
|
||||
var pendingTocWidth: Double?
|
||||
@@ -331,6 +342,7 @@ struct MarkdownWebView: NSViewRepresentable {
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
isPageLoaded = true
|
||||
if let width = pendingTocWidth {
|
||||
lastTocWidth = width
|
||||
webView.evaluateJavaScript("setTOCWidth(\(width))") { _, _ in }
|
||||
|
||||
@@ -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,137 @@ 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;
|
||||
}
|
||||
|
||||
#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;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
html.print-no-backgrounds * {
|
||||
-webkit-print-color-adjust: economy !important;
|
||||
print-color-adjust: economy !important;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 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 +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;
|
||||
@@ -650,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 = [];
|
||||
@@ -1008,6 +1142,7 @@ function setupTOCScrollHighlight() {
|
||||
var timeout;
|
||||
var content = _contentScroller();
|
||||
content.addEventListener('scroll', function() {
|
||||
if (_tocHoverHeadingId) return;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(highlightActiveTOC, 50);
|
||||
});
|
||||
@@ -1036,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;
|
||||
|
||||
@@ -1054,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();
|
||||
@@ -1119,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;
|
||||
|
||||
@@ -23,13 +23,33 @@ struct SettingsView: View {
|
||||
.tabItem {
|
||||
Label("Markdown", systemImage: "doc.plaintext")
|
||||
}
|
||||
|
||||
PrintingSettingsView()
|
||||
.tabItem {
|
||||
Label("Printing", systemImage: "printer")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, maxWidth: .infinity, minHeight: 350, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsScrollView<Content: View>: View {
|
||||
@ViewBuilder var content: Content
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GeneralSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
@AppStorage("defaultTocVisible") private var defaultTocVisible = false
|
||||
@AppStorage("defaultTocDepth") private var defaultTocDepth = 3
|
||||
@AppStorage("defaultRawVisible") private var defaultRawVisible = false
|
||||
@AppStorage("tocWrap") private var tocWrap = false
|
||||
@AppStorage("tocBullets") private var tocBullets = false
|
||||
@State private var isDefault = false
|
||||
@@ -37,10 +57,11 @@ struct GeneralSettingsView: View {
|
||||
@State private var showCSSHelp = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
SettingsScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("General")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox {
|
||||
HStack {
|
||||
@@ -109,6 +130,31 @@ struct GeneralSettingsView: View {
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
HStack {
|
||||
Text("Default Table of Contents depth")
|
||||
Spacer()
|
||||
Picker("Default Table of Contents depth", selection: $defaultTocDepth) {
|
||||
ForEach(1...6, id: \.self) { depth in
|
||||
Text("H\(depth)").tag(depth)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 90)
|
||||
}
|
||||
|
||||
Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(4)
|
||||
}
|
||||
.padding(.top, 12)
|
||||
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -161,8 +207,8 @@ struct GeneralSettingsView: View {
|
||||
.padding(.top, 12)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.onAppear {
|
||||
checkIfDefault()
|
||||
}
|
||||
@@ -207,6 +253,7 @@ struct GeneralSettingsView: View {
|
||||
struct EditorsSettingsView: View {
|
||||
@EnvironmentObject var editorManager: EditorManager
|
||||
@State private var selectedEditorID: UUID?
|
||||
private let editorActionButtonSize: CGFloat = 28
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
@@ -267,7 +314,10 @@ struct EditorsSettingsView: View {
|
||||
addEditor()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
|
||||
Button {
|
||||
if let id = selectedEditorID {
|
||||
@@ -276,16 +326,45 @@ struct EditorsSettingsView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.disabled(selectedEditorID == nil)
|
||||
|
||||
Divider()
|
||||
.frame(height: editorActionButtonSize)
|
||||
.padding(.horizontal, 2)
|
||||
|
||||
Button {
|
||||
moveSelectedEditor(by: -1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.up")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Move selected editor up")
|
||||
.disabled(!canMoveSelectedEditorUp)
|
||||
|
||||
Button {
|
||||
moveSelectedEditor(by: 1)
|
||||
} label: {
|
||||
Image(systemName: "chevron.down")
|
||||
.frame(width: editorActionButtonSize, height: editorActionButtonSize)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
.help("Move selected editor down")
|
||||
.disabled(!canMoveSelectedEditorDown)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let id = selectedEditorID,
|
||||
let index = editorManager.editors.firstIndex(where: { $0.id == id }) {
|
||||
Toggle("Opens folder instead of file", isOn: Binding(
|
||||
get: { editorManager.editors[index].opensFolder },
|
||||
set: { editorManager.editors[index].opensFolder = $0 }
|
||||
set: { editorManager.setOpensFolder($0, for: id) }
|
||||
))
|
||||
.font(.caption)
|
||||
}
|
||||
@@ -295,6 +374,24 @@ struct EditorsSettingsView: View {
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var selectedEditorIndex: Int? {
|
||||
guard let selectedEditorID else { return nil }
|
||||
return editorManager.editors.firstIndex { $0.id == selectedEditorID }
|
||||
}
|
||||
|
||||
private var canMoveSelectedEditorUp: Bool {
|
||||
selectedEditorIndex.map { $0 > 0 } ?? false
|
||||
}
|
||||
|
||||
private var canMoveSelectedEditorDown: Bool {
|
||||
selectedEditorIndex.map { $0 < editorManager.editors.count - 1 } ?? false
|
||||
}
|
||||
|
||||
private func moveSelectedEditor(by offset: Int) {
|
||||
guard let selectedEditorID else { return }
|
||||
editorManager.moveEditor(id: selectedEditorID, by: offset)
|
||||
}
|
||||
|
||||
private func addEditor() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select Application"
|
||||
@@ -316,15 +413,16 @@ struct MarkdownSettingsView: View {
|
||||
@EnvironmentObject var themeManager: ThemeManager
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Markdown Extensions")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 8)
|
||||
SettingsScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Markdown Extensions")
|
||||
.font(.headline)
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Enable or disable inline syntax extensions applied during rendering.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 16)
|
||||
Text("Enable or disable inline syntax extensions applied during rendering.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
GroupBox {
|
||||
VStack(spacing: 0) {
|
||||
@@ -368,8 +466,8 @@ struct MarkdownSettingsView: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private func extensionRow(name: String, example: String, isOn: Binding<Bool>) -> some View {
|
||||
@@ -388,6 +486,140 @@ 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 Zoom")
|
||||
Spacer()
|
||||
Picker("Font Zoom", selection: $printZoom) {
|
||||
Text("Standard (100%)").tag("Standard (100%)")
|
||||
Text("Match Screen").tag("Match Screen")
|
||||
}
|
||||
.frame(width: 200)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Text("Select whether the printed document inherits the screen zoom scaling.")
|
||||
.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?
|
||||
@@ -414,10 +646,6 @@ struct ThemeSettingsView: View {
|
||||
.padding(.bottom, 8)
|
||||
|
||||
HStack {
|
||||
Text("Drag to reorder. Cmd+Shift+↑/↓ cycles through enabled themes.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
|
||||
@@ -172,3 +172,31 @@ 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(),
|
||||
customCSS: "",
|
||||
isBuiltIn: true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -352,6 +352,10 @@ class ThemeManager: ObservableObject {
|
||||
}
|
||||
|
||||
func generateCSS(for theme: Theme) -> String {
|
||||
generateCSS(for: theme, zoomScale: zoomScale)
|
||||
}
|
||||
|
||||
func generateCSS(for theme: Theme, zoomScale: Double) -> String {
|
||||
let bodyFont = theme.fonts.body == "System"
|
||||
? "-apple-system, BlinkMacSystemFont, 'Helvetica Neue', sans-serif"
|
||||
: "'\(theme.fonts.body)', -apple-system, sans-serif"
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class FileMenuPrunerTests: XCTestCase {
|
||||
func testRemovesOnlyRequestedFileMenuItems() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
["New", "Open...", "Open Recent", "Close", "Save", "Duplicate", "Rename", "Save As...", "Revert To", "Page Setup...", "Print..."]
|
||||
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(
|
||||
fileMenu.items.map(\.title),
|
||||
["Open...", "Open Recent", "Close", "Duplicate", "Rename", "Save As...", "Page Setup...", "Print..."]
|
||||
)
|
||||
}
|
||||
|
||||
func testRemovesSaveWithEllipsisWithoutRemovingSaveAs() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
["Save...", "Save As...", "Duplicate", "Rename"]
|
||||
.forEach { fileMenu.addItem(withTitle: $0, action: nil, keyEquivalent: "") }
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(fileMenu.items.map(\.title), ["Save As...", "Duplicate", "Rename"])
|
||||
}
|
||||
|
||||
func testRemovesNestedRevertToSubmenu() {
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open...", action: nil, keyEquivalent: "")
|
||||
let revertItem = NSMenuItem(title: "Revert To", action: nil, keyEquivalent: "")
|
||||
revertItem.submenu = NSMenu(title: "Revert To")
|
||||
fileMenu.addItem(revertItem)
|
||||
fileMenu.addItem(withTitle: "Print...", action: nil, keyEquivalent: "")
|
||||
|
||||
FileMenuPruner.removeEditingItems(from: fileMenu)
|
||||
|
||||
XCTAssertEqual(fileMenu.items.map(\.title), ["Open...", "Print..."])
|
||||
}
|
||||
}
|
||||
|
||||
final class ExternalEditorMenuStateTests: XCTestCase {
|
||||
func testDisabledWhenNoEditorsAreAvailable() {
|
||||
let state = ExternalEditorMenuState(editors: [])
|
||||
|
||||
XCTAssertEqual(state, .disabled)
|
||||
}
|
||||
|
||||
func testSingleEditorUsesAppNameInTitle() {
|
||||
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
let state = ExternalEditorMenuState(editors: [editor])
|
||||
|
||||
XCTAssertEqual(state, .single(editor, title: "Open in TextEdit"))
|
||||
}
|
||||
|
||||
func testMultipleEditorsUseSubmenu() {
|
||||
let textEdit = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
let code = EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
|
||||
let state = ExternalEditorMenuState(editors: [textEdit, code])
|
||||
|
||||
XCTAssertEqual(state, .submenu([textEdit, code]))
|
||||
}
|
||||
|
||||
func testSingleEditorFileMenuItemKeepsEditorIDForAction() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
let editor = EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")
|
||||
manager.editors = [editor]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertEqual(item.representedObject as? String, editor.id.uuidString)
|
||||
XCTAssertTrue(item.target === controller)
|
||||
}
|
||||
|
||||
func testSingleEditorFileMenuItemIsDisabledWithoutOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { nil }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertFalse(item.isEnabled)
|
||||
XCTAssertNil(item.action)
|
||||
XCTAssertNil(item.target)
|
||||
XCTAssertFalse(controller.validateMenuItem(item))
|
||||
}
|
||||
|
||||
func testExternalEditorSubmenuItemsAreDisabledWithoutOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [
|
||||
EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app"),
|
||||
EditorConfig(name: "Visual Studio Code", path: "/Applications/Visual Studio Code.app")
|
||||
]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { nil }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in External Editor"))
|
||||
let submenu = try XCTUnwrap(item.submenu)
|
||||
XCTAssertTrue(item.isEnabled)
|
||||
XCTAssertTrue(submenu.items.allSatisfy { !$0.isEnabled })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { $0.action == nil })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { $0.target == nil })
|
||||
XCTAssertTrue(submenu.items.allSatisfy { !controller.validateMenuItem($0) })
|
||||
}
|
||||
|
||||
func testExternalEditorMenuItemValidationAllowsOpenFile() throws {
|
||||
let controller = ExternalEditorFileMenuController()
|
||||
let manager = EditorManager()
|
||||
manager.editors = [EditorConfig(name: "TextEdit", path: "/System/Applications/TextEdit.app")]
|
||||
controller.editorManager = manager
|
||||
controller.currentFileURLProvider = { URL(fileURLWithPath: "/tmp/test.md") }
|
||||
|
||||
let fileMenu = NSMenu(title: "File")
|
||||
fileMenu.addItem(withTitle: "Open Recent", action: nil, keyEquivalent: "")
|
||||
|
||||
controller.update(in: fileMenu)
|
||||
|
||||
let item = try XCTUnwrap(fileMenu.item(withTitle: "Open in TextEdit"))
|
||||
XCTAssertEqual(item.action, NSSelectorFromString("openInExternalEditor:"))
|
||||
XCTAssertTrue(item.target === controller)
|
||||
XCTAssertTrue(controller.validateMenuItem(item))
|
||||
}
|
||||
|
||||
func testCurrentOpenDocumentFileURLIgnoresDocumentsWithoutVisibleWindows() {
|
||||
let document = NSDocument()
|
||||
document.fileURL = URL(fileURLWithPath: "/tmp/closed.md")
|
||||
document.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
|
||||
|
||||
XCTAssertNil(ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [document]))
|
||||
}
|
||||
|
||||
func testCurrentOpenDocumentFileURLUsesVisibleDocumentWindow() {
|
||||
let hiddenDocument = NSDocument()
|
||||
hiddenDocument.fileURL = URL(fileURLWithPath: "/tmp/hidden.md")
|
||||
hiddenDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: false)))
|
||||
|
||||
let visibleDocument = NSDocument()
|
||||
let visibleURL = URL(fileURLWithPath: "/tmp/visible.md")
|
||||
visibleDocument.fileURL = visibleURL
|
||||
visibleDocument.addWindowController(NSWindowController(window: TestWindow(isVisibleForTest: true)))
|
||||
|
||||
XCTAssertEqual(
|
||||
ExternalEditorFileMenuController.currentOpenDocumentFileURL(in: [hiddenDocument, visibleDocument]),
|
||||
visibleURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestWindow: NSWindow {
|
||||
private let isVisibleForTest: Bool
|
||||
|
||||
init(isVisibleForTest: Bool) {
|
||||
self.isVisibleForTest = isVisibleForTest
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 100, height: 100),
|
||||
styleMask: [],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
override var isVisible: Bool {
|
||||
isVisibleForTest
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
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
|
||||
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"))
|
||||
|
||||
// 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"))
|
||||
|
||||
// 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"))
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import XCTest
|
||||
@testable import markdownViewr
|
||||
|
||||
final class TemplateScrollbarTests: XCTestCase {
|
||||
func testTemplateStylesAllScrollbarsWithThemeColors() throws {
|
||||
@@ -50,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")
|
||||
@@ -70,6 +113,370 @@ final class TemplateScrollbarTests: XCTestCase {
|
||||
XCTAssertTrue(appHelp.contains("scrollbar thumbs"))
|
||||
}
|
||||
|
||||
func testFileMenuKeepsDefaultCommandGroupsForNonEditingItems() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .newItem)"))
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .saveItem)"))
|
||||
XCTAssertTrue(appSource.contains("FileMenuPrunerApplicationDelegate"))
|
||||
|
||||
let prunerSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
XCTAssertTrue(prunerSource.contains("NSMenu.didBeginTrackingNotification"))
|
||||
XCTAssertFalse(prunerSource.contains("NSMenuDelegate"))
|
||||
XCTAssertFalse(prunerSource.contains(".delegate = self"))
|
||||
XCTAssertFalse(prunerSource.contains("func menuNeedsUpdate"))
|
||||
XCTAssertFalse(prunerSource.contains("func applicationWillUpdate"))
|
||||
XCTAssertFalse(prunerSource.contains("func menuWillOpen"))
|
||||
}
|
||||
|
||||
func testExternalEditorCommandIsSeparatedBelowOpenRecent() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains("ExternalEditorFileMenuController.shared.editorManager = editorManager"))
|
||||
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.shared.update(in: fileMenu)"))
|
||||
XCTAssertTrue(menuSource.contains(#"normalizedTitle == "Open Recent""#))
|
||||
XCTAssertTrue(menuSource.contains("NSMenuItem.separator()"))
|
||||
XCTAssertTrue(menuSource.contains("ExternalEditorFileMenuController.currentOpenDocumentFileURL()"))
|
||||
XCTAssertTrue(menuSource.contains("window?.isVisible == true"))
|
||||
XCTAssertFalse(appSource.contains(".disabled(fileURL == nil)"))
|
||||
}
|
||||
|
||||
func testExternalEditorAddRemoveButtonsUseMatchingSize() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains("private let editorActionButtonSize"))
|
||||
XCTAssertTrue(settingsSource.contains(".frame(width: editorActionButtonSize, height: editorActionButtonSize)"))
|
||||
}
|
||||
|
||||
func testExternalEditorSettingsCanReorderConfiguredEditors() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let editorSource = try loadSourceFile("markdownViewr/EditorConfig.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.up")"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Image(systemName: "chevron.down")"#))
|
||||
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: -1)"))
|
||||
XCTAssertTrue(settingsSource.contains("moveSelectedEditor(by: 1)"))
|
||||
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorUp"))
|
||||
XCTAssertTrue(settingsSource.contains("canMoveSelectedEditorDown"))
|
||||
XCTAssertTrue(settingsSource.contains("editorManager.moveEditor(id: selectedEditorID, by: offset)"))
|
||||
XCTAssertTrue(editorSource.contains("func moveEditor(id: UUID, by offset: Int)"))
|
||||
XCTAssertTrue(editorSource.contains("reorderedEditors.swapAt(source, destination)"))
|
||||
XCTAssertTrue(editorSource.contains("editors = reorderedEditors"))
|
||||
}
|
||||
|
||||
func testViewMenuIncludesTogglesForTableOfContentsAndMarkdownSource() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("tocVisible")"#))
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("rawVisible")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocVisible")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawVisible")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocVisible = false"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var rawVisible = false"))
|
||||
XCTAssertTrue(contentSource.contains(".focusedSceneValue(\\.documentViewCommands, documentViewCommands)"))
|
||||
XCTAssertFalse(contentSource.contains(".focusedValue(\\.documentViewCommands, documentViewCommands)"))
|
||||
XCTAssertTrue(appSource.contains("@FocusedValue(\\.documentViewCommands) private var documentViewCommands"))
|
||||
XCTAssertTrue(appSource.contains(#"Toggle("Table of Contents", isOn: documentViewCommands?.tocVisible ?? .constant(false))"#))
|
||||
XCTAssertTrue(appSource.contains(#"Toggle("Markdown Source", isOn: documentViewCommands?.rawVisible ?? .constant(false))"#))
|
||||
}
|
||||
|
||||
func testViewMenuIncludesTableOfContentsDepthSubmenu() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains(#"@AppStorage("tocDepth")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocDepth")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocDepth = 3"))
|
||||
XCTAssertTrue(appSource.contains(#"Menu("Table of Contents Depth")"#))
|
||||
XCTAssertTrue(appSource.contains(#"ForEach(1...6, id: \.self)"#))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.setTocDepth(depth)"))
|
||||
XCTAssertTrue(appSource.contains(#"Label("H\(depth)", systemImage: "checkmark")"#))
|
||||
XCTAssertFalse(appSource.contains(#"Picker("Table of Contents Depth", selection: $tocDepth)"#))
|
||||
}
|
||||
|
||||
func testDocumentViewStateAndZoomAreWindowLocal() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
let themeSource = try loadSourceFile("markdownViewr/ThemeManager.swift")
|
||||
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("tocWidth")"#))
|
||||
XCTAssertFalse(contentSource.contains(#"@AppStorage("rawWidth")"#))
|
||||
XCTAssertTrue(contentSource.contains("@State private var tocWidth: Double = 220"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var rawWidth: Double = 400"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var zoomScale = 1.0"))
|
||||
XCTAssertTrue(contentSource.contains("@State private var activeThemeName: String?"))
|
||||
XCTAssertTrue(contentSource.contains("private var activeTheme: Theme"))
|
||||
XCTAssertTrue(contentSource.contains("themeManager.generateCSS(for: activeTheme, zoomScale: zoomScale)"))
|
||||
XCTAssertTrue(contentSource.contains("activeThemeName = themeManager.activeThemeName"))
|
||||
XCTAssertTrue(contentSource.contains("setZoomScale: { zoomScale = $0 }"))
|
||||
XCTAssertTrue(contentSource.contains("setActiveThemeName: { activeThemeName = $0 }"))
|
||||
XCTAssertTrue(contentSource.contains("zoomScale: Binding("))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomIn()"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomOut()"))
|
||||
XCTAssertTrue(appSource.contains("documentViewCommands?.zoomReset()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomIn()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomOut()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.zoomReset()"))
|
||||
XCTAssertFalse(appSource.contains("themeManager.cycleTheme(direction:"))
|
||||
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = title"))
|
||||
XCTAssertFalse(contentSource.contains("themeManager?.activeThemeName = sender.title"))
|
||||
XCTAssertTrue(themeSource.contains("func generateCSS(for theme: Theme, zoomScale: Double) -> String"))
|
||||
}
|
||||
|
||||
func testSettingsIncludeNewWindowViewDefaults() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
let webViewSource = try loadSourceFile("markdownViewr/MarkdownWebView.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Table of Contents in new windows", isOn: $defaultTocVisible)"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Picker("Default Table of Contents depth", selection: $defaultTocDepth)"#))
|
||||
XCTAssertTrue(settingsSource.contains(#"Toggle("Show Markdown Source in new windows", isOn: $defaultRawVisible)"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocVisible") private var defaultTocVisible = false"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultTocDepth") private var defaultTocDepth = 3"#))
|
||||
XCTAssertTrue(contentSource.contains(#"@AppStorage("defaultRawVisible") private var defaultRawVisible = false"#))
|
||||
XCTAssertTrue(contentSource.contains("initializeWindowViewStateIfNeeded(windowWidth: Double(window.contentView?.bounds.width ?? 0))"))
|
||||
XCTAssertFalse(contentSource.contains(".onAppear {\n initializeWindowViewStateIfNeeded()"))
|
||||
XCTAssertFalse(contentSource.contains("guard windowWidth > 0 else { return }"))
|
||||
XCTAssertTrue(contentSource.contains("tocVisible = defaultTocVisible"))
|
||||
XCTAssertTrue(contentSource.contains("tocDepth = defaultTocDepth"))
|
||||
XCTAssertTrue(contentSource.contains("rawVisible = defaultRawVisible"))
|
||||
XCTAssertTrue(contentSource.contains("sizeInitialRawSourceIfNeeded(windowWidth: windowWidth)"))
|
||||
XCTAssertTrue(contentSource.contains("guard rawVisible && !hasActivatedRawSource && windowWidth > 0 else { return }"))
|
||||
XCTAssertTrue(contentSource.contains("windowWidth: windowWidth"))
|
||||
XCTAssertTrue(webViewSource.contains("var isPageLoaded = false"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.isPageLoaded = false"))
|
||||
XCTAssertTrue(webViewSource.contains("if !context.coordinator.isPageLoaded {"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingTocVisible = tocVisible"))
|
||||
XCTAssertTrue(webViewSource.contains("context.coordinator.pendingRawVisible = rawVisible"))
|
||||
XCTAssertTrue(webViewSource.contains("isPageLoaded = true"))
|
||||
}
|
||||
|
||||
func testSettingsTabsScrollWhenWindowIsSmall() throws {
|
||||
let settingsSource = try loadSourceFile("markdownViewr/SettingsView.swift")
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(settingsSource.contains("private struct SettingsScrollView<Content: View>: View"))
|
||||
XCTAssertTrue(settingsSource.contains("ScrollView(.vertical)"))
|
||||
XCTAssertTrue(settingsSource.contains("content\n .frame(maxWidth: .infinity, alignment: .topLeading)\n .padding(20)"))
|
||||
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"General\")"))
|
||||
XCTAssertTrue(settingsSource.contains("SettingsScrollView {\n VStack(alignment: .leading, spacing: 0) {\n Text(\"Markdown Extensions\")"))
|
||||
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n .onAppear {\n checkIfDefault()"))
|
||||
XCTAssertFalse(settingsSource.contains("}\n .padding(20)\n }\n\n private func extensionRow"))
|
||||
XCTAssertTrue(settingsSource.contains("}\n .padding(20)\n }\n\n private var selectedEditorIndex"))
|
||||
XCTAssertFalse(settingsSource.contains("struct EditorsSettingsView: View {\n var body: some View {\n SettingsScrollView"))
|
||||
XCTAssertFalse(settingsSource.contains("struct ThemeSettingsView: View {\n var body: some View {\n SettingsScrollView"))
|
||||
XCTAssertTrue(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 720)"))
|
||||
XCTAssertTrue(appSource.contains("contentRect: NSRect(x: 0, y: 0, width: 600, height: 720)"))
|
||||
XCTAssertFalse(appSource.contains("hostingView.frame = NSRect(x: 0, y: 0, width: 600, height: 600)"))
|
||||
}
|
||||
|
||||
func testDocumentWindowsUseLandscapeDefaultSize() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains(".defaultSize(width: 1100, height: 800)"))
|
||||
XCTAssertTrue(appSource.contains("static func defaultDocumentWindowSize(tocVisible: Bool, rawVisible: Bool) -> NSSize"))
|
||||
XCTAssertTrue(appSource.contains("let autosaveName = Self.autosaveName(for: fileURL)"))
|
||||
XCTAssertTrue(appSource.contains("let restoredFrame = window.setFrameUsingName(autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("window.setContentSize(Self.defaultDocumentWindowSize(tocVisible: defaultTocVisible, rawVisible: defaultRawVisible))"))
|
||||
XCTAssertTrue(appSource.contains("window.center()"))
|
||||
XCTAssertTrue(appSource.contains("window.setFrameAutosaveName(autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("context.coordinator.configureSavingFrame(for: window, autosaveName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("func updateNSView(_ nsView: NSView, context: Context)"))
|
||||
XCTAssertTrue(appSource.contains("window.saveFrame(usingName: autosaveName)"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.willCloseNotification"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.didResizeNotification"))
|
||||
XCTAssertTrue(appSource.contains("NSWindow.didMoveNotification"))
|
||||
XCTAssertFalse(appSource.contains(".defaultSize(width: 700, height: 900)"))
|
||||
XCTAssertFalse(appSource.contains("if !window.setFrameAutosaveName(autosaveName)"))
|
||||
XCTAssertFalse(appSource.contains("fileURL.path.hashValue"))
|
||||
}
|
||||
|
||||
func testDocumentWindowAutosaveNameIsStableAcrossLaunches() {
|
||||
let fileURL = URL(fileURLWithPath: "/tmp/My File.md")
|
||||
|
||||
XCTAssertEqual(
|
||||
DocumentWindowConfigurator.autosaveName(for: fileURL),
|
||||
"doc-%2Ftmp%2FMy%20File.md"
|
||||
)
|
||||
}
|
||||
|
||||
func testDocumentWindowDefaultWidthAccountsForDefaultSidePanes() {
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: false), NSSize(width: 1100, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: false), NSSize(width: 1250, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: false, rawVisible: true), NSSize(width: 1350, height: 800))
|
||||
XCTAssertEqual(DocumentWindowConfigurator.defaultDocumentWindowSize(tocVisible: true, rawVisible: true), NSSize(width: 1500, height: 800))
|
||||
}
|
||||
|
||||
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),
|
||||
600
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1200, tocVisible: true, tocWidth: 300),
|
||||
450
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 380, tocVisible: true, tocWidth: 260),
|
||||
220
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.initialRawWidth(windowWidth: 1500, tocVisible: true, tocWidth: 220),
|
||||
640
|
||||
)
|
||||
}
|
||||
|
||||
func testSourceWidthOnlyAutoSizesOnFirstActivation() {
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.rawWidthWhenActivating(
|
||||
currentRawWidth: 360,
|
||||
hasActivatedRawSource: false,
|
||||
windowWidth: 1200,
|
||||
tocVisible: true,
|
||||
tocWidth: 300
|
||||
),
|
||||
450
|
||||
)
|
||||
XCTAssertEqual(
|
||||
DocumentViewLayout.rawWidthWhenActivating(
|
||||
currentRawWidth: 360,
|
||||
hasActivatedRawSource: true,
|
||||
windowWidth: 1200,
|
||||
tocVisible: true,
|
||||
tocWidth: 300
|
||||
),
|
||||
360
|
||||
)
|
||||
}
|
||||
|
||||
func testViewAndExternalEditorCommandsHaveKeyboardShortcuts() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let menuSource = try loadSourceFile("markdownViewr/FileMenuPruner.swift")
|
||||
|
||||
XCTAssertTrue(appSource.contains(#".keyboardShortcut("t", modifiers: .command)"#))
|
||||
XCTAssertTrue(appSource.contains(#".keyboardShortcut("u", modifiers: .command)"#))
|
||||
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: .command)"#))
|
||||
XCTAssertFalse(appSource.contains(#".keyboardShortcut("m", modifiers: [.command, .shift])"#))
|
||||
XCTAssertTrue(menuSource.contains(#"keyEquivalent: "e""#))
|
||||
XCTAssertTrue(menuSource.contains("makeEditorMenuItem(title: title, editor: editor, keyEquivalent: \"e\", isEnabled: hasOpenFile)"))
|
||||
XCTAssertTrue(menuSource.contains("keyEquivalent: index == 0 ? \"e\" : \"\""))
|
||||
XCTAssertTrue(menuSource.contains("let hasOpenFile = currentFileURLProvider() != nil"))
|
||||
XCTAssertTrue(menuSource.contains("item.keyEquivalentModifierMask = [.command]"))
|
||||
}
|
||||
|
||||
func testToolbarControlsExposeStableTextLabels() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains("DocumentToolbarController"))
|
||||
XCTAssertTrue(contentSource.contains("static let toc = NSToolbarItem.Identifier(\"toc\")"))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarItemGroup(placement: .automatic)"))
|
||||
XCTAssertFalse(contentSource.contains(#".toolbar(id: "document-toolbar")"#))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarSpacer"))
|
||||
XCTAssertFalse(contentSource.contains("tocControls"))
|
||||
XCTAssertFalse(contentSource.contains("zoomControls"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "TOC""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "TOC Depth""#))
|
||||
XCTAssertFalse(contentSource.contains(#"item.label = "Table of Contents Depth""#))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Markdown Source""#))
|
||||
XCTAssertFalse(contentSource.contains("ToolbarDisplayModeReader"))
|
||||
XCTAssertFalse(contentSource.contains("toolbarDisplayMode"))
|
||||
XCTAssertFalse(contentSource.contains("zoomTextMenu"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Zoom""#))
|
||||
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = zoomMenuItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"NSMenuItem(title: "Zoom", action: nil, keyEquivalent: "")"#))
|
||||
XCTAssertTrue(contentSource.contains("item.view = zoomControlView"))
|
||||
XCTAssertTrue(contentSource.contains("item.target = self"))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(showZoomMenuFromToolbar(_:))"))
|
||||
XCTAssertTrue(contentSource.contains("NSSegmentedControl(labels:"))
|
||||
XCTAssertTrue(contentSource.contains("final class ZoomToolbarControlView"))
|
||||
XCTAssertTrue(contentSource.contains("control.setWidth(56, forSegment: 1)"))
|
||||
XCTAssertTrue(contentSource.contains("private func zoomMenu() -> NSMenu"))
|
||||
XCTAssertTrue(contentSource.contains("menu.popUp(positioning: nil"))
|
||||
XCTAssertTrue(contentSource.contains("showZoomMenuFromToolbar"))
|
||||
XCTAssertTrue(contentSource.contains("zoomSegmentSelected"))
|
||||
XCTAssertFalse(contentSource.contains("dumpToolbar"))
|
||||
XCTAssertFalse(contentSource.contains("toolbar-debug"))
|
||||
XCTAssertFalse(contentSource.contains("ControlGroup"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = "Theme""#))
|
||||
XCTAssertTrue(contentSource.contains("item.menuFormRepresentation = themeMenuItem()"))
|
||||
XCTAssertTrue(contentSource.contains(#"item.label = externalEditorLabel"#))
|
||||
XCTAssertTrue(contentSource.contains("item.action = #selector(openExternalEditorFromToolbar(_:))"))
|
||||
XCTAssertTrue(contentSource.contains("item.autovalidates = false"))
|
||||
XCTAssertTrue(contentSource.contains("item.view = externalEditorButton(isEnabled: !validEditors.isEmpty)"))
|
||||
XCTAssertTrue(contentSource.contains("private func externalEditorButton(isEnabled: Bool) -> NSButton"))
|
||||
XCTAssertTrue(contentSource.contains("button.showsBorderOnlyWhileMouseInside = true"))
|
||||
XCTAssertFalse(contentSource.contains("button.isBordered = false"))
|
||||
XCTAssertTrue(contentSource.contains("func validateToolbarItem(_ item: NSToolbarItem) -> Bool"))
|
||||
XCTAssertTrue(contentSource.contains("return hasValidExternalEditor"))
|
||||
XCTAssertTrue(contentSource.contains("showExternalEditorMenu(from: sender)"))
|
||||
XCTAssertFalse(contentSource.contains("externalEditorPopup"))
|
||||
}
|
||||
|
||||
func testToolbarIsNativelyCustomizable() throws {
|
||||
let appSource = try loadSourceFile("markdownViewr/MarkdownViewrApp.swift")
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertFalse(appSource.contains("CommandGroup(replacing: .toolbar)"))
|
||||
XCTAssertTrue(appSource.contains("CommandGroup(before: .toolbar)"))
|
||||
XCTAssertTrue(contentSource.contains("final class DocumentToolbarController: NSObject"))
|
||||
XCTAssertTrue(contentSource.contains("ObservableObject"))
|
||||
XCTAssertTrue(contentSource.contains("NSToolbarDelegate"))
|
||||
XCTAssertTrue(contentSource.contains("window.toolbar = toolbar"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.delegate = self"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.allowsUserCustomization = true"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.autosavesConfiguration = true"))
|
||||
XCTAssertTrue(contentSource.contains("toolbar.displayMode = NSToolbar.DisplayMode.default"))
|
||||
XCTAssertTrue(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v6")"#))
|
||||
XCTAssertTrue(contentSource.contains("removeLegacySavedToolbarConfigurations()"))
|
||||
XCTAssertTrue(contentSource.contains(#""NSToolbar Configuration \(identifier)""#))
|
||||
XCTAssertTrue(contentSource.contains(#""document-toolbar-v5""#))
|
||||
XCTAssertTrue(contentSource.contains(#""document-toolbar-v4""#))
|
||||
XCTAssertFalse(contentSource.contains(#"NSToolbar.Identifier("document-toolbar-v5")"#))
|
||||
XCTAssertTrue(contentSource.contains("} else if window.toolbar?.delegate !== self {\n window.toolbar?.delegate = self\n }"))
|
||||
XCTAssertTrue(contentSource.contains("item.target = self"))
|
||||
XCTAssertTrue(contentSource.contains("toolbarAllowedItemIdentifiers"))
|
||||
XCTAssertTrue(contentSource.contains("toolbarDefaultItemIdentifiers"))
|
||||
}
|
||||
|
||||
func testDocumentToolbarUsesNativeRepeatableSpaceItem() throws {
|
||||
let contentSource = try loadSourceFile("markdownViewr/ContentView.swift")
|
||||
|
||||
XCTAssertTrue(contentSource.contains(".space"))
|
||||
XCTAssertFalse(contentSource.contains(".flexibleSpace"))
|
||||
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"))
|
||||
XCTAssertFalse(contentSource.contains(#"NSToolbarItem.Identifier("document-fixed-space")"#))
|
||||
XCTAssertFalse(contentSource.contains("Small Space"))
|
||||
}
|
||||
|
||||
private func loadTemplate() throws -> String {
|
||||
let templateURL = try XCTUnwrap(
|
||||
Bundle.main.url(forResource: "template", withExtension: "html")
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ targetTemplates:
|
||||
INFOPLIST_FILE: markdownViewr/Info.plist
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.dkelkhoff.markdownViewr
|
||||
PRODUCT_NAME: markdownViewr
|
||||
MARKETING_VERSION: "1.3.0"
|
||||
CURRENT_PROJECT_VERSION: "3"
|
||||
MARKETING_VERSION: "1.5.0"
|
||||
CURRENT_PROJECT_VERSION: "5"
|
||||
MACOSX_DEPLOYMENT_TARGET: "13.0"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
|
||||
|
||||
Reference in new issue
Block a user