Skip to content

Commit 5edd37f

Browse files
committed
feat(link): add full Markdown link navigation support
- Main App: Enable links to external URLs, local files, and anchors - QuickLook: Show non-intrusive toast for all links due to sandbox limitations - JavaScript: Handle anchor clicks in JS, send other links to Swift via message handler - Swift: Add linkClicked message handler and toast notification system - Documentation: Update CHANGELOG and README with link navigation information
1 parent ee3f6f8 commit 5edd37f

File tree

12 files changed

+315
-22
lines changed

12 files changed

+315
-22
lines changed

CHANGELOG.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
# Changelog
22

33
## [Unreleased]
4-
_无待发布的变更_
4+
5+
### Added
6+
- **链接导航 (Link Navigation)**: 为 Markdown 文档添加完整的链接跳转支持。
7+
- **主应用 (Main App)**:
8+
- 外部 URL 链接 (http/https) 在默认浏览器中打开
9+
- 相对路径链接 (`../file.md`, `./dir/file.md`) 正确解析并打开目标文件
10+
- 页内锚点链接 (`#section`) 平滑滚动定位
11+
- 支持所有文件类型 (`.md`, `.sql`, `.py`, `.json` 等)
12+
- **QuickLook 预览 (Preview)**:
13+
- 由于 macOS 沙盒限制,所有链接点击时显示优雅的 toast 提示而非打扰用户的对话框
14+
- Toast 位置: 顶部居中
15+
- 自动关闭: 3 秒后淡出消失
16+
- 非阻塞: 用户可继续浏览文档
17+
- 防重复: 同时只显示一个 toast
18+
- 提示文案: "QuickLook 预览模式不支持链接跳转 / 请双击 .md 文件用主应用打开以使用完整功能"
19+
- **技术实现**:
20+
- 端到端桥接: JavaScript 通过 `window.webkit.messageHandlers.linkClicked` 将链接信息传递给 Swift
21+
- 页内锚点: JavaScript 直接处理平滑滚动
22+
- 文件路径解析: 支持相对路径、绝对路径、`file://` 协议等多种格式
23+
- 日志增强: 所有链接点击操作记录到系统日志,便于调试
524

625
## [1.10.127] - 2026-02-10
726

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A macOS QuickLook extension to beautifully preview Markdown files with full rend
1414
- **Syntax Highlighting**: Code blocks with language-specific highlighting
1515
- **Emoji**: Full emoji support with `:emoji_name:` syntax
1616
- **Table of Contents**: Auto-generated, collapsible navigation panel with smart highlighting
17+
- **Link Navigation**: Full link support in the main app. Click links to open external URLs, navigate to local files, or scroll to anchors. QuickLook shows a non-intrusive toast due to sandbox limitations.
1718
- **Theme**: Configurable appearance (Light, Dark, or System). Defaults to Light mode for better readability.
1819
- **Zoom**: Keyboard shortcuts (`Cmd +/-/0`), scroll wheel zoom (hold `Cmd` and scroll), and pinch gesture (two-finger pinch) with persistence
1920
- **Scroll Position Memory**: Automatically remembers scroll position for eachMarkdown file and restores it on next preview
@@ -197,6 +198,25 @@ Or simply select any `.md` file in Finder and press Space (QuickLook shortcut).
197198
**To grant permissions without dialog:**
198199
- You can pre-authorize in System Settings before using the app
199200

201+
### Link Navigation in QuickLook
202+
203+
**Problem:** Clicking links in QuickLook preview shows a toast notification instead of navigating.
204+
205+
**Why this happens:**
206+
- macOS App Sandbox restricts QuickLook extensions from opening files or external URLs
207+
- This is a system-level security feature that cannot be bypassed
208+
- The toast is a non-intrusive way to inform users about this limitation
209+
210+
**Solution:**
211+
- Double-click the `.md` file to open it in the main app instead
212+
- The main app has full link navigation support without sandbox restrictions
213+
214+
**Supported link types (Main App only):**
215+
- External URLs: `https://example.com` → Opens in default browser
216+
- Relative paths: `./other.md` or `../dir/file.md` → Opens the target file
217+
- Anchors: `#section` → Smooth scroll to the section
218+
- All file types: `.md`, `.sql`, `.py`, `.json`, etc. → Opens with default app
219+
200220
## Acknowledgements
201221

202222
This project is significantly inspired by and utilizes portions of [markdown-preview-enhanced](https://github.com/shd101wyy/markdown-preview-enhanced), created by Yiyi Wang (shd101wyy). We sincerely thank the author for their excellent work.

Sources/Markdown/Info.plist

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@
1010
<string>AppIcon</string>
1111
<key>CFBundleIconName</key>
1212
<string>AppIcon</string>
13+
<key>CFBundleURLTypes</key>
14+
<array>
15+
<dict>
16+
<key>CFBundleURLName</key>
17+
<string>com.xykong.Markdown</string>
18+
<key>CFBundleURLSchemes</key>
19+
<array>
20+
<string>markdownpreview</string>
21+
</array>
22+
</dict>
23+
</array>
1324
<key>CFBundleDocumentTypes</key>
1425
<array>
1526
<dict>

Sources/Markdown/MarkdownApp.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import SwiftUI
22
import Sparkle
33

44
class AppDelegate: NSObject, NSApplicationDelegate {
5-
// Use SPUStandardUpdaterController for SwiftUI integration
65
let updaterController = SPUStandardUpdaterController(
76
startingUpdater: true,
87
updaterDelegate: nil,
@@ -15,6 +14,37 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1514
if CommandLine.arguments.contains("--register-only") {
1615
NSApplication.shared.terminate(nil)
1716
}
17+
18+
NSAppleEventManager.shared().setEventHandler(
19+
self,
20+
andSelector: #selector(handleURLEvent(_:withReplyEvent:)),
21+
forEventClass: AEEventClass(kInternetEventClass),
22+
andEventID: AEEventID(kAEGetURL)
23+
)
24+
}
25+
26+
@objc func handleURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) {
27+
guard let urlString = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject))?.stringValue,
28+
let url = URL(string: urlString) else {
29+
print("❌ Invalid URL event")
30+
return
31+
}
32+
33+
print("🔵 Received URL: \(urlString)")
34+
35+
if url.scheme == "markdownpreview",
36+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
37+
let path = components.queryItems?.first(where: { $0.name == "path" })?.value {
38+
let fileURL = URL(fileURLWithPath: path)
39+
print("🔵 Opening file: \(fileURL.path)")
40+
NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) { _, _, error in
41+
if let error = error {
42+
print("❌ Failed to open document: \(error.localizedDescription)")
43+
} else {
44+
print("✅ Successfully opened document")
45+
}
46+
}
47+
}
1848
}
1949

2050
// MARK: - Helper Methods

Sources/Markdown/MarkdownWebView.swift

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct MarkdownWebView: NSViewRepresentable {
2121
webConfiguration.processPool = MarkdownWebView.sharedProcessPool
2222
let userContentController = WKUserContentController()
2323
userContentController.add(coordinator, name: "logger")
24+
userContentController.add(coordinator, name: "linkClicked")
2425

2526
let debugSource = """
2627
window.onerror = function(msg, url, line, col, error) {
@@ -89,6 +90,7 @@ struct MarkdownWebView: NSViewRepresentable {
8990
var isWebViewLoaded = false
9091
var pendingRender: (() -> Void)?
9192
weak var currentWebView: WKWebView?
93+
var currentFileURL: URL?
9294

9395
override init() {
9496
super.init()
@@ -218,12 +220,12 @@ struct MarkdownWebView: NSViewRepresentable {
218220
}
219221

220222
func render(webView: WKWebView, content: String, fileURL: URL?) {
221-
// Save the render action
223+
currentFileURL = fileURL
224+
222225
pendingRender = { [weak self] in
223226
self?.executeRender(webView: webView, content: content, fileURL: fileURL)
224227
}
225228

226-
// If already loaded, render immediately
227229
if isWebViewLoaded {
228230
pendingRender?()
229231
pendingRender = nil
@@ -308,7 +310,52 @@ struct MarkdownWebView: NSViewRepresentable {
308310
pendingRender = nil
309311
}
310312
}
313+
} else if message.name == "linkClicked", let href = message.body as? String {
314+
os_log("🔵 Link clicked from JS: %{public}@", log: logger, type: .default, href)
315+
handleLinkClick(href: href)
316+
}
317+
}
318+
319+
private func handleLinkClick(href: String) {
320+
if href.starts(with: "http://") || href.starts(with: "https://") {
321+
if let url = URL(string: href) {
322+
os_log("🔵 Opening external URL: %{public}@", log: logger, type: .default, href)
323+
NSWorkspace.shared.open(url)
324+
}
325+
return
326+
}
327+
328+
guard let fileURL = currentFileURL else {
329+
os_log("🔴 Cannot resolve relative path: no current file URL", log: logger, type: .error)
330+
return
311331
}
332+
333+
let baseDir = fileURL.deletingLastPathComponent()
334+
var targetURL: URL
335+
336+
if href.starts(with: "file://") {
337+
guard let url = URL(string: href) else {
338+
os_log("🔴 Invalid file URL: %{public}@", log: logger, type: .error, href)
339+
return
340+
}
341+
targetURL = url
342+
} else if href.starts(with: "/") {
343+
targetURL = URL(fileURLWithPath: href)
344+
} else {
345+
targetURL = baseDir
346+
for component in href.split(separator: "/") {
347+
let componentStr = String(component)
348+
if componentStr == ".." {
349+
targetURL.deleteLastPathComponent()
350+
} else if componentStr != "." {
351+
targetURL.appendPathComponent(componentStr)
352+
}
353+
}
354+
}
355+
356+
os_log("🔵 Opening local file: %{public}@ (base: %{public}@, href: %{public}@)",
357+
log: logger, type: .default, targetURL.path, baseDir.path, href)
358+
NSWorkspace.shared.open(targetURL)
312359
}
313360

314361
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {

Sources/MarkdownPreview/PreviewViewController.swift

Lines changed: 114 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ public class PreviewViewController: NSViewController, QLPreviewingController, WK
292292

293293
let userContentController = WKUserContentController()
294294
userContentController.add(self, name: "logger")
295+
userContentController.add(self, name: "linkClicked")
295296
webConfiguration.userContentController = userContentController
296297

297298
os_log("🔵 initializing InteractiveWebView instance...", log: logger, type: .default)
@@ -446,6 +447,7 @@ public class PreviewViewController: NSViewController, QLPreviewingController, WK
446447
webView.stopLoading()
447448
webView.navigationDelegate = nil
448449
webView.configuration.userContentController.removeScriptMessageHandler(forName: "logger")
450+
webView.configuration.userContentController.removeScriptMessageHandler(forName: "linkClicked")
449451

450452
for recognizer in webView.gestureRecognizers {
451453
webView.removeGestureRecognizer(recognizer)
@@ -909,7 +911,12 @@ public class PreviewViewController: NSViewController, QLPreviewingController, WK
909911
return
910912
}
911913

912-
os_log("🔵 Link clicked: %{public}@", log: logger, type: .debug, url.absoluteString)
914+
os_log("🔵 Link clicked: %{public}@", log: logger, type: .default, url.absoluteString)
915+
os_log("🔵 - scheme: %{public}@, isFileURL: %{public}@, path: %{public}@",
916+
log: logger, type: .default,
917+
url.scheme ?? "nil",
918+
url.isFileURL ? "YES" : "NO",
919+
url.path)
913920

914921
if let fragment = url.fragment, !fragment.isEmpty {
915922
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
@@ -923,31 +930,28 @@ public class PreviewViewController: NSViewController, QLPreviewingController, WK
923930
let isSameDocument = targetPath.isEmpty || currentPath == targetPath || url.scheme == nil
924931

925932
if isSameDocument {
926-
os_log("🔵 Scrolling to anchor: #%{public}@", log: logger, type: .debug, fragment)
927-
let escapedFragment = fragment.replacingOccurrences(of: "'", with: "\\'")
928-
let js = "document.getElementById('\(escapedFragment)')?.scrollIntoView({behavior:'smooth',block:'start'})"
929-
webView.evaluateJavaScript(js, completionHandler: nil)
933+
os_log("🔵 Same-document anchor link, letting JavaScript handle it", log: logger, type: .default)
930934
decisionHandler(.cancel)
931935
return
932936
}
933937
}
934938

935-
// Handle external links (http/https) - open in default browser
936939
if url.scheme == "http" || url.scheme == "https" {
937-
os_log("🔵 Opening external URL in browser: %{public}@", log: logger, type: .debug, url.absoluteString)
940+
os_log("🔵 Opening external URL in browser: %{public}@", log: logger, type: .default, url.absoluteString)
938941
NSWorkspace.shared.open(url)
939942
decisionHandler(.cancel)
940943
return
941944
}
942945

943-
// Handle local markdown file links - open with QuickLook or default app
944-
if url.isFileURL && url.pathExtension.lowercased() == "md" {
945-
os_log("🔵 Opening local markdown file: %{public}@", log: logger, type: .debug, url.path)
946+
if url.isFileURL {
947+
os_log("🔵 Opening local file with default app: %{public}@ (extension: %{public}@)",
948+
log: logger, type: .default, url.path, url.pathExtension)
946949
NSWorkspace.shared.open(url)
947950
decisionHandler(.cancel)
948951
return
949952
}
950953

954+
os_log("🔵 Allowing navigation (unhandled scheme: %{public}@)", log: logger, type: .default, url.scheme ?? "nil")
951955
decisionHandler(.allow)
952956
}
953957

@@ -965,13 +969,109 @@ public class PreviewViewController: NSViewController, QLPreviewingController, WK
965969
os_log("🟢 Renderer Handshake Received!", log: logger, type: .default)
966970
cancelHandshakeTimeout()
967971

968-
// Always mark as loaded and render.
969-
// We do NOT check !isWebViewLoaded here because a Reload action
970-
// might have reset the WebView content (sending a new handshake)
971-
// without the Swift side catching the navigation start event in time.
972972
isWebViewLoaded = true
973973
renderPendingMarkdown()
974974
}
975+
} else if message.name == "linkClicked", let href = message.body as? String {
976+
os_log("🔵 Link clicked from JS: %{public}@", log: logger, type: .default, href)
977+
handleLinkClick(href: href)
978+
}
979+
}
980+
981+
private func handleLinkClick(href: String) {
982+
if href.starts(with: "http://") || href.starts(with: "https://") {
983+
if let url = URL(string: href) {
984+
os_log("🔵 Opening external URL: %{public}@", log: logger, type: .default, href)
985+
let success = NSWorkspace.shared.open(url)
986+
os_log("🔵 NSWorkspace.open result: %{public}@", log: logger, type: .default, success ? "SUCCESS" : "FAILED")
987+
988+
if !success {
989+
os_log("🔴 Failed to open URL in QuickLook Extension sandbox", log: logger, type: .error)
990+
showLinkUnsupportedToast()
991+
}
992+
}
993+
return
994+
}
995+
996+
os_log("🔵 Local file link clicked: %{public}@", log: logger, type: .default, href)
997+
showLinkUnsupportedToast()
998+
}
999+
1000+
private var toastView: NSView?
1001+
1002+
private func showLinkUnsupportedToast() {
1003+
DispatchQueue.main.async { [weak self] in
1004+
guard let self = self else { return }
1005+
1006+
if self.toastView != nil {
1007+
return
1008+
}
1009+
1010+
let toastContainer = NSView()
1011+
toastContainer.wantsLayer = true
1012+
toastContainer.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.95).cgColor
1013+
toastContainer.layer?.cornerRadius = 8
1014+
toastContainer.translatesAutoresizingMaskIntoConstraints = false
1015+
1016+
let iconImageView = NSImageView()
1017+
iconImageView.image = NSImage(systemSymbolName: "info.circle.fill", accessibilityDescription: nil)
1018+
iconImageView.contentTintColor = .white
1019+
iconImageView.translatesAutoresizingMaskIntoConstraints = false
1020+
1021+
let messageLabel = NSTextField(labelWithString: "QuickLook 预览模式不支持链接跳转")
1022+
messageLabel.textColor = .white
1023+
messageLabel.font = .systemFont(ofSize: 13, weight: .medium)
1024+
messageLabel.translatesAutoresizingMaskIntoConstraints = false
1025+
1026+
let hintLabel = NSTextField(labelWithString: "请双击 .md 文件用主应用打开以使用完整功能")
1027+
hintLabel.textColor = NSColor.white.withAlphaComponent(0.9)
1028+
hintLabel.font = .systemFont(ofSize: 11)
1029+
hintLabel.translatesAutoresizingMaskIntoConstraints = false
1030+
1031+
toastContainer.addSubview(iconImageView)
1032+
toastContainer.addSubview(messageLabel)
1033+
toastContainer.addSubview(hintLabel)
1034+
self.view.addSubview(toastContainer)
1035+
1036+
NSLayoutConstraint.activate([
1037+
toastContainer.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 16),
1038+
toastContainer.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
1039+
toastContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 500),
1040+
1041+
iconImageView.leadingAnchor.constraint(equalTo: toastContainer.leadingAnchor, constant: 12),
1042+
iconImageView.centerYAnchor.constraint(equalTo: toastContainer.centerYAnchor),
1043+
iconImageView.widthAnchor.constraint(equalToConstant: 20),
1044+
iconImageView.heightAnchor.constraint(equalToConstant: 20),
1045+
1046+
messageLabel.leadingAnchor.constraint(equalTo: iconImageView.trailingAnchor, constant: 8),
1047+
messageLabel.trailingAnchor.constraint(equalTo: toastContainer.trailingAnchor, constant: -12),
1048+
messageLabel.topAnchor.constraint(equalTo: toastContainer.topAnchor, constant: 10),
1049+
1050+
hintLabel.leadingAnchor.constraint(equalTo: messageLabel.leadingAnchor),
1051+
hintLabel.trailingAnchor.constraint(equalTo: messageLabel.trailingAnchor),
1052+
hintLabel.topAnchor.constraint(equalTo: messageLabel.bottomAnchor, constant: 2),
1053+
hintLabel.bottomAnchor.constraint(equalTo: toastContainer.bottomAnchor, constant: -10)
1054+
])
1055+
1056+
self.toastView = toastContainer
1057+
1058+
toastContainer.alphaValue = 0
1059+
NSAnimationContext.runAnimationGroup({ context in
1060+
context.duration = 0.3
1061+
toastContainer.animator().alphaValue = 1
1062+
})
1063+
1064+
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
1065+
guard let self = self, let toast = self.toastView else { return }
1066+
1067+
NSAnimationContext.runAnimationGroup({ context in
1068+
context.duration = 0.3
1069+
toast.animator().alphaValue = 0
1070+
}, completionHandler: {
1071+
toast.removeFromSuperview()
1072+
self.toastView = nil
1073+
})
1074+
}
9751075
}
9761076
}
9771077

Tests/fixtures/example.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"name": "markdown-preview-test",
3+
"version": "1.0.0",
4+
"description": "Test file for link navigation"
5+
}

0 commit comments

Comments
 (0)