Files
wehub-resource-sync 5b99bf6bca
CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:34:54 +08:00

633 lines
28 KiB
Swift

import AppKit
import UniformTypeIdentifiers
import EdmundCore
/// NSDocument subclass that provides standard macOS document lifecycle:
/// file open/save, dirty-dot indicator, click-to-rename in the titlebar,
/// recent documents, and more — all for free.
///
/// The actual editing is delegated entirely to `EditorTextView`.
class Document: NSDocument, HeadingNavigable {
var editor: EditorTextView!
private var statusBar: StatusBarView!
private var viewModeButton: NSButton?
private static let viewModeItemID = NSToolbarItem.Identifier("viewMode")
/// Session-only zoom scale (View ▸ Actual Size/Zoom In/Zoom Out), applied on
/// top of the persisted font size and content width. Not saved — each new
/// window starts back at 100%.
private var zoomFactor: CGFloat = 1.0
private static let zoomStep: CGFloat = 0.1
private static let zoomRange: ClosedRange<CGFloat> = 0.5...3.0
/// Editor scroll view and its container, held so Read mode can swap the
/// editor out for a `ReadModeWebView` (created lazily on first read).
private var scrollView: NSScrollView!
private var containerView: NSView!
private var readView: ReadModeWebView?
/// Content loaded from disk before the editor window exists.
/// `nonisolated(unsafe)` because `read(from:ofType:)` may be called
/// off the main actor, but the value is only consumed on main via `showWindows`.
nonisolated(unsafe) var pendingContent: String?
// MARK: - Type Registration
//
// Without an Info.plist (SPM executable), NSDocument's default readableTypes
// and writableTypes are empty, which causes NSDocumentController to disable
// Open/Save entirely. We override them here.
override class var readableTypes: [String] {
["public.plain-text", "net.daringfireball.markdown"]
}
override class var writableTypes: [String] {
["net.daringfireball.markdown", "public.plain-text"]
}
override class var autosavesInPlace: Bool {
AppSettings.autoSaveWithVersions
}
override class func isNativeType(_ name: String) -> Bool {
return readableTypes.contains(name)
}
// A single writable type keeps the save panel from showing a file-format
// popup. Everything we write is markdown, so there's nothing to choose.
override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] {
["net.daringfireball.markdown"]
}
// The `net.daringfireball.markdown` UTI prefers the ".markdown" extension;
// force ".md" instead, which is what people actually expect.
override func fileNameExtension(forType typeName: String,
saveOperation: NSDocument.SaveOperationType) -> String? {
"md"
}
// `fileNameExtension(forType:…)` alone isn't enough: for an untitled save
// the panel still seeds its name field from the markdown UTI's preferred
// extension (".markdown"). Force the default name to end in ".md" and let
// the user type any other extension if they really want one.
override func prepareSavePanel(_ savePanel: NSSavePanel) -> Bool {
savePanel.allowedContentTypes = []
savePanel.allowsOtherFileTypes = true
let base = (savePanel.nameFieldStringValue as NSString).deletingPathExtension
savePanel.nameFieldStringValue = (base.isEmpty ? "Untitled" : base) + ".md"
return true
}
// MARK: - Window Setup
override func makeWindowControllers() {
// Default content size for first launch. Any saved size is applied as a
// full window frame at the end of setup (below), once the toolbar is in
// place — so the frame round-trips exactly and doesn't drift by the
// title bar + toolbar height each time.
let windowWidth: CGFloat = 800
let windowHeight: CGFloat = 560
let window = DocumentWindow(
contentRect: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
styleMask: [.titled, .closable, .resizable, .miniaturizable],
backing: .buffered,
defer: false
)
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
// Don't persist/restore document windows: macOS state restoration
// otherwise reopens the last-edited file on the next launch, so a fresh
// start (or File ▸ New) shows that document instead of a blank Untitled.
window.isRestorable = AppSettings.reopenWindows
window.minSize = NSSize(width: 320, height: 400)
window.backgroundColor = NSColor.textBackgroundColor
// Build the TextKit 2 text system chain (viewport-based layout).
editor = EditorTextView.makeTextKit2(
frame: NSRect(x: 0, y: 0, width: windowWidth, height: windowHeight),
containerSize: NSSize(width: windowWidth, height: CGFloat.greatestFiniteMagnitude)
)
editor.minSize = NSSize(width: 0, height: 0)
editor.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude,
height: CGFloat.greatestFiniteMagnitude)
editor.isVerticallyResizable = true
editor.isHorizontallyResizable = false
editor.autoresizingMask = [.width]
editor.textContainerInset = NSSize(width: 24, height: 18)
// Centered reading column (see EditorTextView+ContentWidth). Convert the
// persisted cm value to points using the main screen PPI at window-creation
// time; recomputed on resize (setFrameSize) and when the window moves to a
// different display (windowDidChangeScreen).
let initScreen = NSScreen.main
editor.maxContentWidthPoints = initScreen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000
editor.updateContentInset()
editor.allowRemoteImages = !AppSettings.blockExternalImages
editor.typewriterModeEnabled = AppDelegate.typewriterModeEnabled()
editor.document = self
// Toolbar holds the right-aligned view-mode toggle (and gives the
// titlebar extra height for roomy traffic lights). Set it only after
// `editor` exists — assigning the toolbar synchronously vends its items.
let toolbar = NSToolbar(identifier: "MainToolbar")
toolbar.delegate = self
toolbar.displayMode = .iconOnly
toolbar.allowsUserCustomization = true
toolbar.autosavesConfiguration = true // persists layout per "MainToolbar"
window.toolbar = toolbar
window.toolbarStyle = .unified
window.titlebarSeparatorStyle = .line
// Wire the window's secondary-click interception now that the toolbar has
// synchronously vended the view-mode button (see DocumentWindow).
window.viewModeButton = viewModeButton
window.makeViewModeMenu = { [weak self] in self?.viewModeMenu() ?? NSMenu() }
let statusBarHeight: CGFloat = 22
let contentBounds = window.contentView!.bounds
// The text view fills the whole window; the status bar floats over its
// bottom edge, revealed on hover.
scrollView = NSScrollView(frame: contentBounds)
scrollView.autoresizingMask = [.width, .height]
scrollView.hasVerticalScroller = true
scrollView.scrollerStyle = .overlay
scrollView.drawsBackground = false
scrollView.documentView = editor
// Floating status bar: hidden by default, fades in when the pointer
// enters its strip. Counts on the left, line ending on the right.
statusBar = StatusBarView(frame: NSRect(
x: 0, y: 0, width: contentBounds.width, height: statusBarHeight
))
statusBar.autoresizingMask = [.width]
containerView = NSView(frame: contentBounds)
containerView.autoresizesSubviews = true
containerView.addSubview(scrollView)
containerView.addSubview(statusBar) // overlay, on top of the text
window.contentView = containerView
NotificationCenter.default.addObserver(
self, selector: #selector(editorDidChange(_:)),
name: NSText.didChangeNotification, object: editor
)
NotificationCenter.default.addObserver(
self, selector: #selector(editorSelectionDidChange(_:)),
name: NSTextView.didChangeSelectionNotification, object: editor
)
NotificationCenter.default.addObserver(
self, selector: #selector(windowDidResize(_:)),
name: NSWindow.didResizeNotification, object: window
)
NotificationCenter.default.addObserver(
self, selector: #selector(windowDidChangeScreen(_:)),
name: NSWindow.didChangeScreenNotification, object: window
)
// Restore the last window's frame size (the toolbar is now installed, so
// the frame is final). Applied as a frame, not a contentRect, so it
// round-trips exactly with what windowDidResize saves. Then center.
if let savedSize = AppSettings.lastWindowSize {
window.setFrame(NSRect(origin: window.frame.origin, size: savedSize), display: false)
}
window.center()
let wc = NSWindowController(window: window)
addWindowController(wc)
window.makeFirstResponder(editor)
// Honor the persisted source-mode preference for the editing view.
if AppSettings.sourceMode { setViewMode(.source) }
updateStatusBar()
}
@objc private func windowDidResize(_ notification: Notification) {
guard let window = notification.object as? NSWindow else { return }
// Save the full frame size; it's restored verbatim via setFrame on the
// next window, so the size round-trips exactly (no title-bar/toolbar drift).
AppSettings.lastWindowSize = window.frame.size
}
/// Reapply the content-width cap in points when the window moves to a
/// display with a different physical PPI (e.g. external monitor).
@objc private func windowDidChangeScreen(_ notification: Notification) {
guard let window = notification.object as? NSWindow,
let screen = window.screen else { return }
editor?.maxContentWidthPoints = screen.cmToPoints(AppSettings.maxContentWidthCm) * zoomFactor
}
// MARK: - Zoom (View ▸ Actual Size / Zoom In / Zoom Out)
@objc func zoomIn(_ sender: Any?) { setZoom(zoomFactor + Self.zoomStep) }
@objc func zoomOut(_ sender: Any?) { setZoom(zoomFactor - Self.zoomStep) }
@objc func actualSize(_ sender: Any?) { setZoom(1.0) }
/// Scales font size (standard + code) and max content width together by
/// `factor`, off the persisted base values — never off the currently
/// applied (possibly already-zoomed) theme, so repeated zooming doesn't
/// compound rounding error and Actual Size always returns to the true base.
private func setZoom(_ factor: CGFloat) {
guard let editor else { return }
zoomFactor = min(Self.zoomRange.upperBound, max(Self.zoomRange.lowerBound, factor))
let base = EditorTheme.load(from: editor.themeDefaults)
var zoomed = base
zoomed.fontSize = base.fontSize * zoomFactor
zoomed.monospaceFontSize = base.monospaceFontSize * zoomFactor
editor.applyTheme(zoomed, persist: false)
let screen = editor.window?.screen ?? NSScreen.main
editor.maxContentWidthPoints = (screen?.cmToPoints(AppSettings.maxContentWidthCm) ?? 1000) * zoomFactor
refreshReadView()
}
@objc private func editorDidChange(_ notification: Notification) {
updateStatusBar()
// Keep an open Read view in sync with edits (it renders a snapshot).
refreshReadView()
}
@objc private func editorSelectionDidChange(_ notification: Notification) {
updateStatusBar()
}
private func updateStatusBar() {
guard let editor = editor, let statusBar = statusBar else { return }
let text = editor.rawSource
let nsText = text as NSString
let wordCount = text.split { $0.isWhitespace || $0.isNewline }.count
let charCount = text.count
// Cursor position: 0-based character location and 1-based line number.
let cursorOffset = editor.selectedRange().location
let location = min(cursorOffset, nsText.length)
let upToCursor = nsText.substring(to: location)
let line = upToCursor.isEmpty ? 1 : upToCursor.components(separatedBy: "\n").count
// The buffer is always LF; show the file's remembered original ending.
statusBar.setMetrics(words: wordCount, characters: charCount,
location: location, line: line,
lineEnding: editor.originalLineEnding.displayName)
}
// MARK: - Reading
override nonisolated func read(from data: Data, ofType typeName: String) throws {
guard let contents = String(data: data, encoding: .utf8) else {
Log.error("Read failed: \(data.count) bytes not valid UTF-8", category: .io)
throw NSError(domain: NSOSStatusErrorDomain, code: -1,
userInfo: [NSLocalizedDescriptionKey: "Could not read file as UTF-8"])
}
Log.info("Read \(data.count) bytes from disk", category: .io)
pendingContent = contents
}
/// Called after makeWindowControllers when opening an existing file.
override func showWindows() {
super.showWindows()
if let content = pendingContent {
editor?.loadContent(content)
pendingContent = nil
warnIfInconsistentLineEndings(in: content)
}
updateStatusBar()
}
/// Warn (once, suppressibly) when an opened file mixed line-ending styles.
/// The buffer has already been normalized to a single style for editing.
private func warnIfInconsistentLineEndings(in content: String) {
guard LineEnding.isInconsistent(in: content),
!AppSettings.suppressInconsistentLineEndingWarning,
let window = windowControllers.first?.window else { return }
let alert = NSAlert()
alert.messageText = "Inconsistent Line Endings"
alert.informativeText = "This document mixes different line endings. "
+ "It will be saved using \(editor?.originalLineEnding.displayName ?? "LF") throughout."
alert.addButton(withTitle: "OK")
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "Do not warn about inconsistent line endings"
alert.beginSheetModal(for: window) { _ in
if alert.suppressionButton?.state == .on {
AppSettings.suppressInconsistentLineEndingWarning = true
}
}
}
/// Cross-file link following: scroll this document's editor to a heading
/// once it's on screen (the content has already loaded in showWindows).
func navigateToHeading(_ heading: String) {
editor?.scrollToHeading(heading)
}
// MARK: - Rename & Move (manual — NSDocument's built-in versions
// are disabled without Info.plist / .app bundle)
override func rename(_ sender: Any?) {
guard let url = fileURL, let window = windowControllers.first?.window else { return }
let panel = NSSavePanel()
panel.directoryURL = url.deletingLastPathComponent()
panel.nameFieldStringValue = url.lastPathComponent
panel.prompt = "Rename"
panel.beginSheetModal(for: window) { response in
guard response == .OK, let newURL = panel.url else { return }
do {
try FileManager.default.moveItem(at: url, to: newURL)
self.fileURL = newURL
} catch {
NSAlert(error: error).runModal()
}
}
}
override func move(_ sender: Any?) {
guard let url = fileURL, let window = windowControllers.first?.window else { return }
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.canCreateDirectories = true
panel.prompt = "Move"
panel.message = "Choose a new location for \"\(url.lastPathComponent)\""
panel.beginSheetModal(for: window) { response in
guard response == .OK, let destDir = panel.url else { return }
let newURL = destDir.appendingPathComponent(url.lastPathComponent)
do {
try FileManager.default.moveItem(at: url, to: newURL)
self.fileURL = newURL
} catch {
NSAlert(error: error).runModal()
}
}
}
// MARK: - View Mode (edit / reading / source)
private func icon(for mode: EditorTextView.ViewMode) -> NSImage? {
let name: String
switch mode {
// Source is a raw-text view of the same editing mode as Edit, so it
// shares the pencil icon rather than getting a distinct glyph.
case .edit, .source: name = "pencil"
case .reading: name = "book"
}
return NSImage(systemSymbolName: name, accessibilityDescription: label(for: mode))
}
private func label(for mode: EditorTextView.ViewMode) -> String {
switch mode {
case .edit: return "Edit"
case .reading: return "Read"
case .source: return "Source"
}
}
/// Shows the active mode's icon on the button and keeps the tooltip in sync.
private func refreshViewModeButton() {
guard let editor else { return }
viewModeButton?.image = icon(for: editor.viewMode)?
.withSymbolConfiguration(.init(pointSize: 13, weight: .regular))
viewModeButton?.toolTip = "View mode: \(label(for: editor.viewMode))"
}
private func setViewMode(_ mode: EditorTextView.ViewMode) {
editor.viewMode = mode
applyViewMode(mode)
refreshViewModeButton()
}
/// Swaps the on-screen view for the mode: Read mode shows the rendered-HTML
/// `ReadModeWebView`; Edit and Source stay on the editor's scroll view.
private func applyViewMode(_ mode: EditorTextView.ViewMode) {
guard let containerView else { return }
if mode == .reading {
let read = readView ?? {
let v = ReadModeWebView()
v.frame = scrollView.frame
v.autoresizingMask = [.width, .height]
// Below the floating status bar so counts stay visible.
containerView.addSubview(v, positioned: .below, relativeTo: statusBar)
// Route internal navigation through the editor's link resolver
// (which resolves against this document's directory and opens via
// NSDocumentController) instead of navigating the webview.
v.onOpenWikiLink = { [weak self] in self?.editor.followWikiLink($0) }
v.onOpenInternalLink = { [weak self] in self?.editor.followLinkDestination($0) }
readView = v
return v
}()
read.render(markdown: editor.rawSource,
theme: editor.theme,
callouts: mergedCallouts,
baseURL: documentDirectory,
options: renderOptions)
read.isHidden = false
scrollView.isHidden = true
editor.window?.makeFirstResponder(read)
} else {
readView?.isHidden = true
scrollView.isHidden = false
editor.window?.makeFirstResponder(editor)
}
}
/// Re-renders an open Read view from the editor's current source + theme.
/// No-op unless Read mode is the active, visible view — so settings/edit
/// broadcasts stay cheap when the user is in Edit or Source mode.
func refreshReadView() {
guard let read = readView, !read.isHidden, editor?.viewMode == .reading else { return }
read.render(markdown: editor.rawSource,
theme: editor.theme,
callouts: mergedCallouts,
baseURL: documentDirectory,
options: renderOptions)
}
/// The opened file's directory, used to resolve relative image paths for
/// inlining (nil for an unsaved document).
private var documentDirectory: URL? {
fileURL?.deletingLastPathComponent()
}
/// Built-in callout styles merged with the editor's user overrides, so Read
/// mode and the PDF match exactly what the editor draws.
private var mergedCallouts: [String: CalloutStyle] {
var m = Callout.defaultStyles
for (k, v) in editor.calloutStyleOverrides { m[k] = v }
return m
}
/// Read-mode/export render options derived from user settings. Reuses the
/// editor's own `maxContentWidthPoints` (already the cm setting converted via
/// the window's screen PPI) so Read mode's column matches Edit mode's.
private var renderOptions: ReadRenderOptions {
ReadRenderOptions(preserveBlankLines: AppSettings.renderBlankLinesAsBreaks,
allowRemoteImages: !AppSettings.blockExternalImages,
maxContentWidthPoints: Double(editor.maxContentWidthPoints))
}
// MARK: - Export / Print
@objc func exportToPDF(_ sender: Any?) {
let name = (displayName as NSString).deletingPathExtension
MarkdownPrinter.exportPDF(markdown: editor.rawSource,
theme: editor.theme,
callouts: mergedCallouts,
baseURL: documentDirectory,
options: renderOptions,
suggestedName: name.isEmpty ? "Untitled" : name,
window: windowControllers.first?.window)
}
@objc override func printDocument(_ sender: Any?) {
MarkdownPrinter.print(markdown: editor.rawSource,
theme: editor.theme,
callouts: mergedCallouts,
baseURL: documentDirectory,
options: renderOptions,
window: windowControllers.first?.window)
}
/// The editing-side view: Source when source mode is on, otherwise Edit.
/// Read is the other half of the toggle.
private var editingMode: EditorTextView.ViewMode {
AppSettings.sourceMode ? .source : .edit
}
@objc private func selectEditMode(_ sender: Any?) { setViewMode(editingMode) }
@objc private func selectReadingMode(_ sender: Any?) { setViewMode(.reading) }
/// The "Show source in editor" checkbox (button menu and View menu).
/// Persists the setting and, if we're in the editing view, swaps it to
/// the new editing mode right away.
@objc func toggleSourceMode(_ sender: Any?) {
AppSettings.sourceMode.toggle()
if editor.viewMode != .reading { setViewMode(editingMode) }
}
/// Keeps the View-menu "Show Source in Editor" checkmark in sync with the setting.
override func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(toggleSourceMode(_:)) {
item.state = AppSettings.sourceMode ? .on : .off
}
return super.validateMenuItem(item)
}
/// Toggle the editing view ↔ Read (the View-menu ⌘E item and the toolbar
/// button). With source mode on the editing view is Source, so this flips
/// Source ↔ Read; otherwise Edit ↔ Read.
@objc func toggleViewMode(_ sender: Any?) {
setViewMode(editor.viewMode == .reading ? editingMode : .reading)
}
/// One mode menu item: icon + title, checked when `on`.
private func menuItem(_ title: String, _ image: NSImage?,
_ action: Selector, on: Bool) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.image = image
item.state = on ? .on : .off
return item
}
/// The right-click menu: Edit / Read selection, a divider, then the
/// "Show source in editor" checkbox. Built fresh each time so state stays current.
fileprivate func viewModeMenu() -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false // actions always fire on selection
let inEditing = editor?.viewMode != .reading
menu.addItem(menuItem("Edit", icon(for: .edit),
#selector(selectEditMode(_:)), on: inEditing))
menu.addItem(menuItem("Read", icon(for: .reading),
#selector(selectReadingMode(_:)), on: !inEditing))
menu.addItem(.separator())
menu.addItem(menuItem("Show source in editor", nil,
#selector(toggleSourceMode(_:)), on: AppSettings.sourceMode))
return menu
}
// MARK: - Writing
override func data(ofType typeName: String) throws -> Data {
// The buffer is always LF; restore the file's original line ending on
// write so opening, then saving, doesn't silently rewrite every line.
let normalized = editor?.rawSource ?? ""
let ending = editor?.originalLineEnding ?? .lf
let text = ending == .lf
? normalized
: normalized.replacingOccurrences(of: "\n", with: ending.string)
guard let data = text.data(using: .utf8) else {
Log.error("Save failed: could not encode \(text.count) chars as UTF-8", category: .io)
throw NSError(domain: NSOSStatusErrorDomain, code: -1,
userInfo: [NSLocalizedDescriptionKey: "Could not encode text as UTF-8"])
}
Log.info("Saving \(data.count) bytes (\(ending.displayName))", category: .io)
return data
}
}
// MARK: - Toolbar (view-mode toggle)
extension Document: NSToolbarDelegate {
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.flexibleSpace, Self.viewModeItemID]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[.flexibleSpace, .space, Self.viewModeItemID]
}
func toolbar(_ toolbar: NSToolbar,
itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
guard itemIdentifier == Self.viewModeItemID else { return nil }
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.label = "View Mode"
item.visibilityPriority = .high
// Left-click toggles the editing view ↔ Read. The right-click mode menu
// is handled upstream in DocumentWindow.sendEvent — every view-level
// approach (the view's `menu`, rightMouseDown, a gesture recognizer)
// loses the secondary click to the toolbar's "Customize Toolbar…" menu.
let button = NSButton(image: NSImage(), target: self,
action: #selector(toggleViewMode(_:)))
button.bezelStyle = .texturedRounded
button.imagePosition = .imageOnly
viewModeButton = button
item.view = button
refreshViewModeButton()
return item
}
}
/// Document window that intercepts a secondary (right / control) click on the
/// view-mode toolbar button and shows the mode menu itself. `sendEvent` is the
/// single funnel all window events pass through *before* the toolbar/titlebar
/// can turn the click into its own "Customize Toolbar…" context menu, so this is
/// the one place the interception reliably wins.
final class DocumentWindow: NSWindow {
weak var viewModeButton: NSView?
var makeViewModeMenu: (() -> NSMenu)?
override func sendEvent(_ event: NSEvent) {
if isSecondaryClick(event), let button = viewModeButton,
button.bounds.contains(button.convert(event.locationInWindow, from: nil)),
let menu = makeViewModeMenu?() {
menu.popUp(positioning: nil,
at: NSPoint(x: 0, y: button.bounds.maxY + 4), in: button)
return
}
super.sendEvent(event)
}
private func isSecondaryClick(_ event: NSEvent) -> Bool {
switch event.type {
case .rightMouseDown: return true
case .leftMouseDown: return event.modifierFlags.contains(.control)
default: return false
}
}
}