Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,7 @@ fastlane/test_output

# Editor bundle build (scripts/editor-bundle)
node_modules/

# Local design tooling
.superpowers/
.worktrees/
2 changes: 1 addition & 1 deletion Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<key>CFBundleTypeName</key>
<string>Markdown Document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>LSItemContentTypes</key>
Expand Down
129 changes: 96 additions & 33 deletions md-preview/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ private enum AppAppearanceMode: String, CaseIterable {
}

@main
final class AppDelegate: NSObject, NSApplicationDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuItemValidation {

@IBOutlet private weak var checkForUpdatesMenuItem: NSMenuItem?

Expand All @@ -111,14 +111,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private weak var darkAppearanceMenuItem: NSMenuItem?
private weak var normalContentWidthMenuItem: NSMenuItem?
private weak var fullContentWidthMenuItem: NSMenuItem?
private var isOpeningDocumentFromPrompt = false
private var undoMenuDefaultTitle = "Undo"
private var redoMenuDefaultTitle = "Redo"
private var isPromptingForDocument = false
private var isDocumentPromptScheduled = false
private var documentPromptScheduleGeneration = 0
private var isUntitledDocumentScheduled = false
private var untitledDocumentScheduleGeneration = 0
private var didReceiveOpenURLsDuringLaunch = false
private var hasFinishedLaunching = false
private var pendingOpenURLCount = 0
private weak var activeOpenPanel: NSOpenPanel?
private var isTerminationSaveInProgress = false
private var pendingTerminationSaveCount = 0
private var terminationSaveFailed = false
Expand All @@ -128,6 +128,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
CrashReporter.start()
applyAppearanceMode(AppAppearanceMode.current, reloadPreviews: false)
installUndoMenuItems()
installAppearanceMenuItems()
installContentWidthMenuItems()
installSidebarViewMenuItems()
Expand All @@ -140,7 +141,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
installZoomMenuItemIcons()
hasFinishedLaunching = true
if !didReceiveOpenURLsDuringLaunch {
scheduleDocumentPrompt(requiresNoDocuments: true)
scheduleUntitledDocument(requiresNoDocuments: true)
}
}

Expand All @@ -149,13 +150,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool {
scheduleDocumentPrompt(requiresNoDocuments: true)
scheduleUntitledDocument(requiresNoDocuments: true)
return true
}

func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !flag {
scheduleDocumentPrompt(requiresNoDocuments: true)
scheduleUntitledDocument(requiresNoDocuments: true)
return false
}
return true
Expand All @@ -165,7 +166,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
if !hasFinishedLaunching {
didReceiveOpenURLsDuringLaunch = true
}
cancelScheduledDocumentPrompt()
cancelScheduledUntitledDocument()

for url in urls {
if url.isExistingDirectory {
Expand All @@ -182,7 +183,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard let self else { return }
self.pendingOpenURLCount -= 1
if error != nil, self.pendingOpenURLCount == 0 {
self.scheduleDocumentPrompt(requiresNoDocuments: true)
self.scheduleUntitledDocument(requiresNoDocuments: true)
}
}
}
Expand Down Expand Up @@ -261,6 +262,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
activeDocumentWindowController?.handleFindAction(sender)
}

@objc private func performUndo(_ sender: Any?) {
if let editor = undoTargetEditor {
editor.exec("undo")
} else {
undoTargetManager?.undo()
}
}

@objc private func performRedo(_ sender: Any?) {
if let editor = undoTargetEditor {
editor.exec("redo")
} else {
undoTargetManager?.redo()
}
}

@objc private func hideSidebarFromMenu(_ sender: Any?) {
activeDocumentWindowController?.hideSidebarFromMenu(sender)
syncSidebarViewMenuState()
Expand Down Expand Up @@ -316,6 +333,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return activeDocumentWindowController?.canToggleEditMode ?? false
case #selector(formatMarkdownFromMenu(_:)):
return activeDocumentWindowController?.canFormatMarkdown ?? false
case #selector(performUndo(_:)):
if let editor = undoTargetEditor {
menuItem.title = undoMenuDefaultTitle
return editor.canUndo
}
let undoManager = undoTargetManager
menuItem.title = undoManager?.undoMenuItemTitle ?? undoMenuDefaultTitle
return undoManager?.canUndo ?? false
case #selector(performRedo(_:)):
if let editor = undoTargetEditor {
menuItem.title = redoMenuDefaultTitle
return editor.canRedo
}
let redoManager = undoTargetManager
menuItem.title = redoManager?.redoMenuItemTitle ?? redoMenuDefaultTitle
return redoManager?.canRedo ?? false
default:
return true
}
Expand All @@ -334,53 +367,64 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
.first
}

/// The window that owns Undo/Redo. An attached sheet takes precedence over
/// the document blocked behind it, and `keyWindow` is nil while a menu tracks.
private var undoWindow: NSWindow? {
let window = NSApp.keyWindow ?? NSApp.mainWindow
return window?.attachedSheet ?? window
}

/// The editor that should receive Undo/Redo, or nil when AppKit's own
/// responder chain owns them (preview mode, a field editor, a sheet).
private var undoTargetEditor: EditorViewController? {
(undoWindow?.windowController as? DocumentWindowController)?.focusedEditorForUndo
}

private var undoTargetManager: UndoManager? {
guard let window = undoWindow else { return nil }
return window.firstResponder?.undoManager ?? window.undoManager
}

private func promptForDocument() {
guard !isPromptingForDocument else { return }
isPromptingForDocument = true
defer { isPromptingForDocument = false }

let panel = makeOpenPanel()
activeOpenPanel = panel
defer { activeOpenPanel = nil }
guard panel.runModal() == .OK, let url = panel.url else { return }
if url.isExistingDirectory {
openFolder(url)
return
}

isOpeningDocumentFromPrompt = true
NSDocumentController.shared.openDocument(withContentsOf: url,
display: true) { [weak self] _, _, error in
self?.isOpeningDocumentFromPrompt = false
display: true) { _, _, error in
guard let error else { return }
NSAlert(error: error).runModal()
}
}

private func scheduleDocumentPrompt(requiresNoDocuments: Bool = false) {
guard !isPromptingForDocument,
!isDocumentPromptScheduled else { return }

isDocumentPromptScheduled = true
documentPromptScheduleGeneration += 1
let scheduleGeneration = documentPromptScheduleGeneration
private func scheduleUntitledDocument(requiresNoDocuments: Bool = false) {
guard !isUntitledDocumentScheduled else { return }
isUntitledDocumentScheduled = true
untitledDocumentScheduleGeneration += 1
let generation = untitledDocumentScheduleGeneration
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard self.documentPromptScheduleGeneration == scheduleGeneration else { return }
self.isDocumentPromptScheduled = false
guard let self,
self.untitledDocumentScheduleGeneration == generation else { return }
self.isUntitledDocumentScheduled = false
guard !requiresNoDocuments || NSDocumentController.shared.documents.isEmpty else { return }
guard !self.isPromptingForDocument else { return }
guard self.pendingOpenURLCount == 0 else { return }
NSApp.activate(ignoringOtherApps: true)
self.promptForDocument()
NSDocumentController.shared.newDocument(nil)
}
}

private func cancelScheduledDocumentPrompt() {
// Dismiss a prompt that already made it past the generation check and
// is sitting in runModal() when the open event arrives.
activeOpenPanel?.cancel(nil)
guard isDocumentPromptScheduled else { return }
documentPromptScheduleGeneration += 1
isDocumentPromptScheduled = false
private func cancelScheduledUntitledDocument() {
guard isUntitledDocumentScheduled else { return }
untitledDocumentScheduleGeneration += 1
isUntitledDocumentScheduled = false
}

private func openFolder(_ url: URL) {
Expand Down Expand Up @@ -629,6 +673,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

// The nib's Undo/Redo know only the window's native undo manager.
// Retarget them so a focused CodeMirror editor can expose its own history.
private func installUndoMenuItems() {
let undoAction = NSSelectorFromString("undo:")
let redoAction = NSSelectorFromString("redo:")
guard let editMenu = topLevelSubmenu(matching: Self.editMenuTitles),
let undoItem = editMenu.items.first(where: { $0.action == undoAction }),
let redoItem = editMenu.items.first(where: { $0.action == redoAction })
else { return }

undoMenuDefaultTitle = undoItem.title
redoMenuDefaultTitle = redoItem.title
undoItem.target = self
undoItem.action = #selector(performUndo(_:))
redoItem.target = self
redoItem.action = #selector(performRedo(_:))
}

private func installNewTabMenuItem() {
guard let fileMenu = topLevelSubmenu(matching: Self.fileMenuTitles),
fileMenu.items.first(where: {
Expand Down Expand Up @@ -1083,6 +1145,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
topLevelMenuItem(matching: titles)?.submenu
}

private static let editMenuTitles: Set<String> = ["Edit", "编辑"]
private static let fileMenuTitles: Set<String> = ["File", "文件"]
private static let viewMenuTitles: Set<String> = ["View", "显示"]
private static let windowMenuTitles: Set<String> = ["Window", "窗口"]
Expand Down
Loading