Adds TextSubstitutionPolicy/TextCompletionPolicy/WritingToolsPolicy to MarkdownEditorConfiguration (previously hardcoded AppKit calls with no config surface). Outline's existing synced smartText preference now actually drives smart quotes/dashes. Autocomplete and Writing Tools get new local-only Settings -> Editor toggles (writingToolsBehavior was already on unconditionally with no way to turn it off).
735 lines
30 KiB
Swift
735 lines
30 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import MarkdownEngine
|
|
import MarkdownEngineCodeBlocks
|
|
import OutlineKit
|
|
|
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
|
/// below must run on the main thread — see the identical note on
|
|
/// `DocumentNodeRow` in `CollectionDocumentsOutline.swift`.
|
|
@MainActor
|
|
struct DocumentReaderView: View {
|
|
@Environment(SessionStore.self) private var session
|
|
@Environment(StarStore.self) private var starStore
|
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
|
/// Local-only Outpost setting (Settings → Editor), not synced to
|
|
/// Outline — see `SettingsView.editorDetail`.
|
|
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
|
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
|
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
|
|
|
/// Outline's own "Show line numbers" preference (synced, read via
|
|
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
|
/// server's, not a local-only Outpost setting). No `@Environment`-in-`init`
|
|
/// problem here since this is read directly in the view, not the
|
|
/// view model.
|
|
private var showCodeBlockLineNumbers: Bool {
|
|
session.userPreferences?.codeBlockLineNumbers ?? false
|
|
}
|
|
/// Widened left indent reserved for the number gutter when line numbers
|
|
/// are on (default is 12pt, just enough margin, no room for digits).
|
|
private static let lineNumberGutterWidth: CGFloat = 32
|
|
private var editorCodeBlockStyle: CodeBlockStyle {
|
|
showCodeBlockLineNumbers ? .init(horizontalIndent: Self.lineNumberGutterWidth) : .default
|
|
}
|
|
/// Outline's own "Smart text replacements" preference (synced) — smart
|
|
/// quotes/dashes while typing. Only meaningful on the editable pane.
|
|
private var editorTextSubstitution: TextSubstitutionPolicy {
|
|
let enabled = session.userPreferences?.smartText ?? false
|
|
return .init(quoteSubstitution: enabled, dashSubstitution: enabled)
|
|
}
|
|
/// Local-only Outpost settings (Settings → Editor) — not synced to
|
|
/// Outline, same as Split View above.
|
|
private var editorTextCompletion: TextCompletionPolicy {
|
|
.init(isEnabled: isAutocompleteEnabled)
|
|
}
|
|
private var editorWritingTools: WritingToolsPolicy {
|
|
.init(isEnabled: isWritingToolsEnabled)
|
|
}
|
|
|
|
@State private var viewModel: DocumentReaderViewModel
|
|
let apiClient: OutlineAPIClient
|
|
let document: OutlineDocument
|
|
/// Pushes a genuine new stack entry (not a reset+replace) — the back
|
|
/// button then correctly returns to this document, not the collection.
|
|
let onOpenChild: (OutlineDocument) -> Void
|
|
/// Called after Delete/Archive/Unpublish succeed — the document is no
|
|
/// longer visible in the collection it was opened from, so the reader
|
|
/// pops itself off the navigation stack.
|
|
let onDeleted: () -> Void
|
|
/// The reader's own "New Document" toolbar button has no direct handle
|
|
/// on the sidebar row it belongs under — this tells the sidebar a
|
|
/// document exists now so it can pick it up. See
|
|
/// `CollectionDocumentsOutline.externalRefreshToken`.
|
|
let onDocumentCreated: () -> Void
|
|
|
|
@State private var isShowingUnpublishConfirmation = false
|
|
@State private var isShowingArchiveConfirmation = false
|
|
@State private var isShowingDeleteConfirmation = false
|
|
@State private var isShowingMoveSheet = false
|
|
@State private var isShowingHistorySheet = false
|
|
@State private var isShowingInsightsSheet = false
|
|
@State private var isShowingPresentSheet = false
|
|
@State private var isShowingSearchSheet = false
|
|
@State private var isShowingShareSheet = false
|
|
@State private var isShowingNewDocumentSheet = false
|
|
@State private var actionErrorMessage: String?
|
|
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
|
/// one array per instance (main pane, split-view preview pane), since
|
|
/// each lays the same text out at a different width and gets different
|
|
/// rects. Only non-empty when `showCodeBlockLineNumbers` is on (see its
|
|
/// doc comment for why the gutter needs `codeBlock.horizontalIndent`
|
|
/// widened, which is gated on the same flag).
|
|
@State private var readerCodeBlocks: [CodeBlockSelection] = []
|
|
@State private var previewCodeBlocks: [CodeBlockSelection] = []
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
document: OutlineDocument,
|
|
onOpenChild: @escaping (OutlineDocument) -> Void,
|
|
onDeleted: @escaping () -> Void,
|
|
onDocumentCreated: @escaping () -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.document = document
|
|
// `separateEditingEnabled` can't be read from `@Environment` here —
|
|
// environment values aren't populated yet inside a view's `init`,
|
|
// only from `body` onward. Defaults to `true` (today's only
|
|
// behavior) and gets set for real in `.task` below once `session`
|
|
// is actually available.
|
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
|
self.onOpenChild = onOpenChild
|
|
self.onDeleted = onDeleted
|
|
self.onDocumentCreated = onDocumentCreated
|
|
}
|
|
|
|
/// New Document, editing, pin/star/subscribe, and Full Width all queue
|
|
/// and sync later (see `CachingOutlineAPIClient`) — everything else here
|
|
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
|
|
/// history, insights, export) hits the server directly with no offline
|
|
/// path, so it's disabled rather than left to fail confusingly on tap.
|
|
private var isEffectivelyOnline: Bool {
|
|
session.networkMonitor.isOnline && !isOfflineModeEnabled
|
|
}
|
|
|
|
/// Split View needs the full window height (each pane scrolls itself),
|
|
/// which an unbounded page-level `ScrollView` can't give it — a
|
|
/// `minHeight` inside one just resolves to exactly that minimum, not
|
|
/// "fill available space", since there's no bounded space to fill.
|
|
/// Only switches over once there's real content to show; loading/error
|
|
/// states still go through the normal scrolling layout.
|
|
private var canShowSplitView: Bool {
|
|
isSplitViewEnabled
|
|
&& viewModel.isEffectivelyEditable
|
|
&& viewModel.errorMessage == nil
|
|
&& !(viewModel.isLoading && viewModel.text.isEmpty)
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if canShowSplitView {
|
|
splitViewContent
|
|
} else {
|
|
scrollingReaderContent
|
|
}
|
|
}
|
|
.overlay(alignment: .topTrailing) {
|
|
if viewModel.isLoading && !viewModel.text.isEmpty {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.padding(12)
|
|
}
|
|
}
|
|
// No `.navigationTitle` here either — same reason as CollectionOverviewView.
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .primaryAction) {
|
|
viewerAvatars
|
|
Button {
|
|
isShowingShareSheet = true
|
|
} label: {
|
|
Image(systemName: "square.and.arrow.up")
|
|
}
|
|
.help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
|
|
.disabled(!isEffectivelyOnline)
|
|
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
|
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
|
}
|
|
|
|
if viewModel.separateEditingEnabled {
|
|
Button {
|
|
Task { await viewModel.toggleEditing() }
|
|
} label: {
|
|
if viewModel.isSaving {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text(viewModel.isEditing ? "Done" : "Edit")
|
|
}
|
|
}
|
|
.disabled(viewModel.isSaving)
|
|
} else if viewModel.isSaving {
|
|
// No Edit/Done affordance when documents are always
|
|
// editable — this is the only feedback that an autosave
|
|
// is actually happening.
|
|
ProgressView().controlSize(.small)
|
|
.help("Saving…")
|
|
}
|
|
|
|
Button {
|
|
isShowingNewDocumentSheet = true
|
|
} label: {
|
|
Image(systemName: "doc.badge.plus")
|
|
}
|
|
.help("New Document")
|
|
|
|
Menu {
|
|
menuContent
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
// SwiftUI's macOS `Menu` doesn't reliably re-evaluate a
|
|
// `Toggle`'s checkmark against updated @Observable state on
|
|
// its own — without a fresh `.id()` per state combination,
|
|
// toggling Subscribed/Viewer Insights/Full Width kept
|
|
// showing the pre-toggle checkmark until the whole view was
|
|
// torn down and rebuilt (e.g. navigating away and back).
|
|
.id(menuIdentity)
|
|
}
|
|
}
|
|
.task { await viewModel.loadFullContent() }
|
|
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
|
// for why this can't just be read at `init` time.
|
|
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
|
|
.onChange(of: viewModel.text) {
|
|
guard !viewModel.separateEditingEnabled else { return }
|
|
viewModel.scheduleAutosave()
|
|
}
|
|
.onChange(of: viewModel.title) {
|
|
guard !viewModel.separateEditingEnabled else { return }
|
|
viewModel.scheduleAutosave()
|
|
}
|
|
.task {
|
|
await viewModel.loadPinAndSubscriptionState()
|
|
}
|
|
.task {
|
|
await viewModel.loadInsightsEnabledState()
|
|
}
|
|
.task {
|
|
while !Task.isCancelled {
|
|
await viewModel.loadViewers()
|
|
try? await Task.sleep(for: .seconds(20))
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Unpublish \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingUnpublishConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Unpublish", role: .destructive) {
|
|
Task { await unpublish() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This moves the document back to a draft and out of the collection.")
|
|
}
|
|
.confirmationDialog(
|
|
"Archive \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingArchiveConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Archive", role: .destructive) {
|
|
Task { await archive() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("Archived documents are hidden from the collection but can be restored later.")
|
|
}
|
|
.confirmationDialog(
|
|
"Delete \"\(viewModel.title.isEmpty ? "Untitled" : viewModel.title)\"?",
|
|
isPresented: $isShowingDeleteConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
Task { await delete() }
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This moves the document to the trash. If not restored within 30 days it's permanently deleted.")
|
|
}
|
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
|
Button("OK") { actionErrorMessage = nil }
|
|
} message: {
|
|
Text(actionErrorMessage ?? "")
|
|
}
|
|
.sheet(isPresented: $isShowingMoveSheet) {
|
|
MoveDocumentSheet(apiClient: apiClient, document: document) {
|
|
onDeleted()
|
|
}
|
|
}
|
|
.sheet(isPresented: $isShowingHistorySheet) {
|
|
DocumentHistorySheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingInsightsSheet) {
|
|
DocumentInsightsSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingPresentSheet) {
|
|
DocumentPresentSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingSearchSheet) {
|
|
DocumentSearchSheet(apiClient: apiClient, document: document)
|
|
}
|
|
.sheet(isPresented: $isShowingNewDocumentSheet) {
|
|
NewDocumentSheet(apiClient: apiClient, initialParentDocument: document) { child in
|
|
onDocumentCreated()
|
|
onOpenChild(child)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Every toggle-backed piece of state shown as a checkmark inside
|
|
/// `menuContent` — see the `.id()` comment on the `Menu` above.
|
|
private var menuIdentity: String {
|
|
[
|
|
starStore.isStarred(documentId: viewModel.documentId),
|
|
viewModel.isSubscribed,
|
|
viewModel.isPinned,
|
|
viewModel.isInsightsEnabled ?? false,
|
|
viewModel.isFullWidth,
|
|
viewModel.isEditing,
|
|
isEffectivelyOnline
|
|
].map(String.init).joined(separator: "-")
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var viewerAvatars: some View {
|
|
// Tied to the Viewer Insights toggle — that's the feature this data
|
|
// belongs to, so turning it off should hide the avatars immediately
|
|
// rather than leaving them showing until the view reloads.
|
|
if viewModel.isInsightsEnabled == true, !viewModel.viewers.isEmpty {
|
|
HStack(spacing: -6) {
|
|
ForEach(viewModel.viewers.prefix(5)) { viewer in
|
|
AvatarBadge(
|
|
avatarURL: viewer.user.avatarUrl.flatMap { URL(string: $0, relativeTo: session.serverURL)?.absoluteURL },
|
|
size: 20
|
|
)
|
|
.overlay(Circle().stroke(.background, lineWidth: 1.5))
|
|
.help(Text(viewer.user.name))
|
|
}
|
|
}
|
|
.padding(.trailing, 4)
|
|
}
|
|
}
|
|
|
|
/// Today's single-pane layout — page-level `ScrollView` wrapping title +
|
|
/// content, used for the normal reading/editing view, and for every
|
|
/// loading/error state regardless of Split View.
|
|
private var scrollingReaderContent: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if viewModel.isEffectivelyEditable {
|
|
TextField("Title", text: $viewModel.title)
|
|
.font(.largeTitle.weight(.bold))
|
|
.textFieldStyle(.plain)
|
|
}
|
|
|
|
if viewModel.isLoading && viewModel.text.isEmpty {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity)
|
|
} else if let errorMessage = viewModel.errorMessage {
|
|
ContentUnavailableView {
|
|
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
|
|
} description: {
|
|
Text(errorMessage)
|
|
} actions: {
|
|
Button("Retry") {
|
|
Task { await viewModel.loadFullContent() }
|
|
}
|
|
}
|
|
} else {
|
|
ZStack(alignment: .topLeading) {
|
|
NativeTextViewWrapper(
|
|
text: $viewModel.text,
|
|
configuration: .init(
|
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
|
codeBlock: editorCodeBlockStyle,
|
|
textSubstitution: editorTextSubstitution,
|
|
textCompletion: editorTextCompletion,
|
|
writingTools: editorWritingTools,
|
|
heightBehavior: .fitsContent
|
|
),
|
|
documentId: viewModel.documentId,
|
|
isEditable: viewModel.isEffectivelyEditable,
|
|
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
|
|
)
|
|
if showCodeBlockLineNumbers {
|
|
ForEach(readerCodeBlocks) { selection in
|
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
/// Split View's layout — title fixed at the top (not part of either
|
|
/// scrolling pane), `splitEditorView` filling every remaining pixel of
|
|
/// the window below it. No outer `ScrollView` here on purpose: each
|
|
/// pane already scrolls itself, and nesting that inside another
|
|
/// unbounded scroll container is exactly what was capping both panes
|
|
/// at a fixed height instead of spanning the window.
|
|
private var splitViewContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
TextField("Title", text: $viewModel.title)
|
|
.font(.largeTitle.weight(.bold))
|
|
.textFieldStyle(.plain)
|
|
.padding([.horizontal, .top])
|
|
|
|
splitEditorView
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
/// Left is a plain, unrendered raw-text editor (deliberately not
|
|
/// `NativeTextViewWrapper` — just the literal Markdown source); right
|
|
/// is the same rich rendering used everywhere else in the app,
|
|
/// read-only, bound to the same `viewModel.text` so it updates live as
|
|
/// the left side is typed into.
|
|
///
|
|
/// Scroll position between the two panes is **not** synchronized — the
|
|
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
|
/// private internal view hierarchy to find its scroll view (the package
|
|
/// exposes no scroll position/delegate hook at all), which is fragile
|
|
/// enough to break silently on a package update. Flagged as a known
|
|
/// follow-up, not attempted here.
|
|
private var splitEditorView: some View {
|
|
HSplitView {
|
|
TextEditor(text: $viewModel.text)
|
|
.font(.system(.body, design: .monospaced))
|
|
.scrollContentBackground(.hidden)
|
|
.padding(8)
|
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
|
|
|
ScrollView {
|
|
ZStack(alignment: .topLeading) {
|
|
NativeTextViewWrapper(
|
|
text: $viewModel.text,
|
|
configuration: .init(
|
|
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
|
codeBlock: editorCodeBlockStyle,
|
|
heightBehavior: .fitsContent
|
|
),
|
|
documentId: viewModel.documentId,
|
|
isEditable: false,
|
|
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
|
|
)
|
|
if showCodeBlockLineNumbers {
|
|
ForEach(previewCodeBlocks) { selection in
|
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
|
}
|
|
}
|
|
}
|
|
.padding(8)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var menuContent: some View {
|
|
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
|
|
Task { await star() }
|
|
}
|
|
Toggle("Subscribed", isOn: Binding(
|
|
get: { viewModel.isSubscribed },
|
|
set: { _ in Task { await toggleSubscription() } }
|
|
))
|
|
|
|
Divider()
|
|
|
|
if viewModel.separateEditingEnabled {
|
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
|
Task { await viewModel.toggleEditing() }
|
|
}
|
|
}
|
|
// Membership management now lives in DocumentShareSheet's "People
|
|
// with access" section, alongside the share link — same sheet,
|
|
// same isShowingShareSheet state.
|
|
Button("Permissions…") {
|
|
isShowingShareSheet = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
|
|
Divider()
|
|
|
|
Button("Templatize") {
|
|
Task { await templatize() }
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
Button("Duplicate") {
|
|
Task { await duplicate() }
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
Button("Unpublish") {
|
|
isShowingUnpublishConfirmation = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
Button("Archive…") {
|
|
isShowingArchiveConfirmation = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
|
|
Divider()
|
|
|
|
Button("Move") {
|
|
isShowingMoveSheet = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
// Multipart file upload is its own subsystem — deferred rather than
|
|
// half-built here.
|
|
Button("Import Document…") {}
|
|
.disabled(true)
|
|
Button("New Document") {
|
|
isShowingNewDocumentSheet = true
|
|
}
|
|
Button(viewModel.isPinned ? "Unpin from Home" : "Pin to Home") {
|
|
Task { await togglePin() }
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("History") {
|
|
isShowingHistorySheet = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
Button("Insights") {
|
|
isShowingInsightsSheet = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
// Present/Search in Document both read `documents.info`, which is
|
|
// read-through cached — they work offline on whatever's cached.
|
|
Button("Present") {
|
|
isShowingPresentSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Download") {
|
|
Task { await download() }
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
Button("Copy") {
|
|
Task { await copyMarkdown() }
|
|
}
|
|
Button("Print") {
|
|
Task { await printDocument() }
|
|
}
|
|
Button("Search in Document") {
|
|
isShowingSearchSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Toggle("Viewer Insights", isOn: Binding(
|
|
get: { viewModel.isInsightsEnabled ?? false },
|
|
set: { _ in Task { await toggleInsights() } }
|
|
))
|
|
.disabled(!isEffectivelyOnline)
|
|
// Confirmed against a live server: there's no per-document embeds
|
|
// field. Only a workspace-level setting exists, and that's not
|
|
// reachable via the API either (no `team.update` endpoint in the
|
|
// vendored spec) — disabled rather than kept as a broken action.
|
|
Button("Enable Embeds") {}
|
|
.disabled(true)
|
|
Toggle("Full Width", isOn: Binding(
|
|
get: { viewModel.isFullWidth },
|
|
set: { _ in Task { await toggleFullWidth() } }
|
|
))
|
|
|
|
Divider()
|
|
|
|
Button("Delete…", role: .destructive) {
|
|
isShowingDeleteConfirmation = true
|
|
}
|
|
.disabled(!isEffectivelyOnline)
|
|
}
|
|
|
|
private func star() async {
|
|
do {
|
|
try await starStore.toggleDocument(viewModel.documentId, apiClient: apiClient)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update the star on this document.")
|
|
}
|
|
}
|
|
|
|
private func togglePin() async {
|
|
do {
|
|
try await viewModel.togglePin()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update pin state.")
|
|
}
|
|
}
|
|
|
|
private func toggleSubscription() async {
|
|
do {
|
|
try await viewModel.toggleSubscription()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update subscription state.")
|
|
}
|
|
}
|
|
|
|
private func toggleFullWidth() async {
|
|
do {
|
|
try await viewModel.toggleFullWidth()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't change document width.")
|
|
}
|
|
}
|
|
|
|
private func toggleInsights() async {
|
|
do {
|
|
try await viewModel.toggleViewerInsights()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't update viewer insights.")
|
|
}
|
|
}
|
|
|
|
private func templatize() async {
|
|
do {
|
|
_ = try await apiClient.templatizeDocument(TemplatizeDocumentRequest(id: viewModel.documentId))
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't templatize this document.")
|
|
}
|
|
}
|
|
|
|
private func duplicate() async {
|
|
do {
|
|
_ = try await apiClient.duplicateDocument(DuplicateDocumentRequest(id: viewModel.documentId))
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't duplicate this document.")
|
|
}
|
|
}
|
|
|
|
private func unpublish() async {
|
|
do {
|
|
_ = try await apiClient.unpublishDocument(UnpublishDocumentRequest(id: viewModel.documentId))
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't unpublish this document.")
|
|
}
|
|
}
|
|
|
|
private func archive() async {
|
|
do {
|
|
_ = try await apiClient.archiveDocument(id: viewModel.documentId)
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't archive this document.")
|
|
}
|
|
}
|
|
|
|
private func delete() async {
|
|
do {
|
|
try await apiClient.deleteDocument(DeleteDocumentRequest(id: viewModel.documentId))
|
|
onDeleted()
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete this document.")
|
|
}
|
|
}
|
|
|
|
private func download() async {
|
|
do {
|
|
let markdown = try await apiClient.exportDocument(id: viewModel.documentId)
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = "\(viewModel.title.isEmpty ? "Untitled" : viewModel.title).md"
|
|
panel.allowedContentTypes = [.text]
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
try markdown.write(to: url, atomically: true, encoding: .utf8)
|
|
} catch {
|
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't download this document.")
|
|
}
|
|
}
|
|
|
|
private func copyMarkdown() async {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(viewModel.text, forType: .string)
|
|
}
|
|
|
|
/// No access to the reader's rendered `NSTextView` (MarkdownEngine
|
|
/// doesn't expose it), so this prints the raw markdown source as plain
|
|
/// monospaced text via a standalone `NSTextView` built just for the print
|
|
/// job, rather than a styled rendering of the document.
|
|
private func printDocument() async {
|
|
let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 612, height: 792))
|
|
textView.string = viewModel.text
|
|
textView.font = .monospacedSystemFont(ofSize: 11, weight: .regular)
|
|
|
|
let operation = NSPrintOperation(view: textView)
|
|
operation.printInfo.horizontalPagination = .fit
|
|
operation.printInfo.verticalPagination = .automatic
|
|
operation.run()
|
|
}
|
|
}
|
|
|
|
/// One code block's number gutter, positioned absolutely over a
|
|
/// `NativeTextViewWrapper` via `CodeBlockSelection.rect` — same overlay
|
|
/// pattern MarkdownEngine's own `CodeBlockButton` uses.
|
|
///
|
|
/// `selection.rect` spans the WHOLE fenced block (open fence line + content
|
|
/// + close fence line), matching what the engine actually lays out — the
|
|
/// fence lines render with invisible (`.clear`) text once the caret leaves
|
|
/// the block, but they don't collapse to zero height, so the block is
|
|
/// always exactly `content line count + 2` rows tall. `selection.code` is
|
|
/// content only, so the row height and number positions below both account
|
|
/// for that phantom top/bottom row explicitly instead of dividing by the
|
|
/// content line count alone (which would drift the numbers upward, more so
|
|
/// per line, the taller the block).
|
|
///
|
|
/// Known limitation, accepted rather than fixable app-side: a content line
|
|
/// that soft-wraps onto a second visual row (MarkdownEngine always
|
|
/// char-wraps code blocks, no way to opt out without forking the package)
|
|
/// throws this off — every row below it reads one line low. Documented in
|
|
/// TODO.local.md alongside the same package's other gaps.
|
|
private struct CodeBlockLineNumberGutter: View {
|
|
let selection: CodeBlockSelection
|
|
let gutterWidth: CGFloat
|
|
|
|
/// `selection.code` (`token.contentRange`) always ends with exactly one
|
|
/// trailing `\n` per content line — the range runs right up to the
|
|
/// start of the closing fence's own line, so the newline that ends the
|
|
/// last content line is included, but there's never an unterminated
|
|
/// final line to add one more for. Counting `\n` characters directly
|
|
/// (not `.components(separatedBy:).count`, which is one too many
|
|
/// whenever the string ends in the separator) is what makes a
|
|
/// single-line block read "1", not "2".
|
|
private var contentLineCount: Int {
|
|
max(1, selection.code.reduce(into: 0) { count, char in if char == "\n" { count += 1 } })
|
|
}
|
|
|
|
var body: some View {
|
|
let totalRows = CGFloat(contentLineCount + 2)
|
|
let rowHeight = selection.rect.height / totalRows
|
|
ForEach(0..<contentLineCount, id: \.self) { line in
|
|
Text("\(line + 1)")
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: gutterWidth - 6, alignment: .trailing)
|
|
.position(
|
|
x: selection.rect.minX + (gutterWidth - 6) / 2,
|
|
// +1.5 rows: skip the invisible open-fence row, then
|
|
// center within this content row.
|
|
y: selection.rect.minY + rowHeight * (CGFloat(line) + 1.5)
|
|
)
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
#endif
|