Files
Outpost/Outpost/Features/Collections/DocumentReaderView.swift
T
Puranjay Savar Mattas 2d0972de1e feat: create inline (anchored) comments from a text selection
Right-click a selection -> "Comment on Selection..." -> opens the
comments sheet straight into composing, with a removable chip showing
what's being anchored to (clearing it falls back to a plain
document-level comment). Posts via comments.create with anchorText
set to the selection.

Only wired on rendered panes (main reader pane, Split View's preview
pane) - not the raw-markdown pane, since a selection there would
capture literal Markdown syntax as the anchor instead of clean prose.

NSMenuItem has no closure initializer, so this needed a small
target-is-self subclass (ClosureMenuItem) to wire a Swift closure
into onBuildContextMenu's NSMenu without a separate selector per
action.

Also moved the comments sheet's .sheet(...) modifier off the toolbar
badge button (which only exists once the doc already has comments)
onto the view root, since a document with zero comments still needs
to be able to open the sheet to create its first one. Reader now
refetches its own comment list after any create/reply via the sheet's
new onCommentsChanged callback, so a freshly anchored comment's inline
marker appears without reopening the document.
2026-08-20 21:52:13 +01:00

1038 lines
45 KiB
Swift

#if os(macOS)
import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `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
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
/// eligibility) — a supported OS with an unsupported Mac still reports
/// `false`, so this is the one check that matters, not just `#available`.
private var isImagePlaygroundSupported: Bool {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
return ImagePlaygroundViewController.isAvailable
}
#endif
return false
}
/// 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 isShowingPublishSheet = 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 isShowingCommentsSheet = false
/// Fetched once on load, purely to drive the toolbar marker/badge and
/// the inline anchor bars below — the sheet itself fetches its own copy
/// independently (see `DocumentCommentsSheet`), same as every other
/// self-contained sheet in this file.
@State private var loadedComments: [OutlineComment] = []
/// Comment id to scroll/focus to when the sheet opens — set when an
/// inline anchor bar is tapped, `nil` for the plain toolbar marker.
@State private var focusedCommentId: String?
/// Resolved on-screen positions for each anchored comment's text, from
/// `NativeTextViewWrapper.onCommentAnchorRectsChange`.
@State private var commentAnchorRects: [CommentAnchorRect] = []
/// Set from "Comment on Selection…" in the editor's right-click menu —
/// the sheet opens straight into composing a new anchored comment.
@State private var pendingCommentAnchorText: String?
private var commentCount: Int? {
loadedComments.isEmpty ? nil : loadedComments.count
}
private var commentAnchorQueries: [CommentAnchorQuery] {
loadedComments.compactMap { comment in
guard let anchorText = comment.anchorText, !anchorText.isEmpty else { return nil }
return CommentAnchorQuery(id: comment.id, anchorText: anchorText)
}
}
@State private var isShowingPresentSheet = false
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String?
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
/// Resolves `![alt](url)` images in this document — shared by both
/// panes (main + split-view preview), since they render the same text.
@State private var imageProvider: OutlineImageProvider
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value —
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
/// notices the provider's `fingerprint()` changed) once an async image
/// load completes. The engine has no polling of its own for this.
@State private var imageReloadTick = 0
/// Snapshot of `currentSelectedText` taken the moment the Image
/// Playground button is pressed — the sheet's seed shouldn't shift if
/// the user's selection happens to change while it's open.
@State private var imagePlaygroundSeedText: 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))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
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)
}
// Toolbar marker — only shows once there's actually
// something to point at. Anchored comments additionally get
// an inline vertical bar next to their text (see
// `commentAnchorMarkers`/`onCommentAnchorRectsChange`
// below) — this button opens the sheet unfocused, showing
// every comment/thread.
if let commentCount, commentCount > 0 {
Button {
focusedCommentId = nil
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
} label: {
Image(systemName: "bubble.left.and.bubble.right")
}
.help("\(commentCount) Comment\(commentCount == 1 ? "" : "s")")
.disabled(!isEffectivelyOnline)
.overlay(alignment: .topTrailing) {
Text(commentCount > 10 ? "10+" : "\(commentCount)")
.font(.system(size: 8, weight: .bold))
.foregroundStyle(.white)
.padding(2)
.frame(minWidth: 12, minHeight: 12)
.background(.blue, in: Circle())
.offset(x: 0, y: -1)
}
}
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
Button {
imagePlaygroundSeedText = currentSelectedText
isShowingImagePlayground = true
} label: {
Image(systemName: "sparkles")
}
.help("Create Image with Image Playground")
.disabled(!viewModel.isEffectivelyEditable)
}
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 {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.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 {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? []
}
.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: $isShowingCommentsSheet) {
DocumentCommentsSheet(
apiClient: apiClient,
document: document,
focusedCommentId: focusedCommentId,
pendingAnchorText: pendingCommentAnchorText,
onCommentsChanged: {
Task {
loadedComments = (try? await apiClient.listComments(
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
)) ?? loadedComments
}
}
)
}
.sheet(isPresented: $isShowingPublishSheet) {
PublishDocumentSheet(apiClient: apiClient, document: document) {
// Stays in the reader — unlike Move/Unpublish, publishing
// doesn't make the document any less visible from here, so
// there's no reason to pop back like onDeleted() does
// elsewhere. Refresh from the server rather than guessing
// the new collectionId locally (moveDocument may have run
// as a second step inside the sheet).
await viewModel.loadFullContent()
// The doc previously had no collectionId (or a different
// one) — the sidebar tree for its new collection needs to
// know it exists now, same signal "New Document" sends.
onDocumentCreated()
}
}
.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)
}
}
.modifier(ImagePlaygroundPresenter(
isPresented: $isShowingImagePlayground,
seedText: imagePlaygroundSeedText,
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
onCompletion: { url in handleGeneratedImage(url) }
))
}
/// Adds "Comment on Selection…" to the right-click menu when there's an
/// actual (non-empty) selection to anchor to — `currentSelectedText` is
/// kept live by `onSelectedTextChange` on the same pane. Inserted at the
/// top since it's the primary reason to right-click selected text here,
/// matching Outline's own web editor surfacing comment as the first
/// selection action.
private func addCommentMenuItem(to menu: NSMenu) -> NSMenu {
guard let selection = currentSelectedText, !selection.isEmpty else { return menu }
let item = ClosureMenuItem(title: "Comment on Selection…") {
pendingCommentAnchorText = selection
focusedCommentId = nil
isShowingCommentsSheet = true
}
menu.insertItem(item, at: 0)
menu.insertItem(.separator(), at: 1)
return menu
}
/// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the
/// direct file POST — see `OutlineAPIClient.uploadAttachmentFile`), then
/// inserts the hosted image's Markdown reference at the caret.
private func handleGeneratedImage(_ localURL: URL) {
Task {
do {
let data = try Data(contentsOf: localURL)
let created = try await apiClient.createAttachment(.init(
name: localURL.lastPathComponent,
contentType: "image/png",
size: data.count,
documentId: viewModel.documentId
))
try await apiClient.uploadAttachmentFile(created, fileData: data)
// Outline's own editor never embeds the raw (presigned/storage)
// upload URL in document Markdown — it writes this stable
// redirect-by-id reference instead, which keeps resolving
// correctly even if the underlying storage URL rotates/expires.
pendingTextInsertion = TextInsertionRequest(
documentId: viewModel.documentId,
text: "\n\n![](/api/attachments.redirect?id=\(created.attachment.id))\n\n"
)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
}
}
}
/// 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,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
writingTools: editorWritingTools,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
}
}
.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 the literal Markdown source in `rawSourceMode` (no syntax
/// hiding/styling, but still the real engine — needed so selection
/// tracking and caret-position insertion, e.g. from the Image Playground
/// button, work here the same as everywhere else); 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 {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(rawSourceMode: true),
fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onBuildContextMenu: { menu, _ in addCommentMenuItem(to: menu) },
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 },
commentAnchorQueries: commentAnchorQueries,
onCommentAnchorRectsChange: { commentAnchorRects = $0 }
)
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
}
}
ForEach(commentAnchorRects) { anchor in
CommentAnchorMarker(rect: anchor.rect) {
focusedCommentId = anchor.id
pendingCommentAnchorText = nil
isShowingCommentsSheet = true
}
}
}
.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)
if viewModel.publishedAt == nil {
Button("Publish…") {
isShowingPublishSheet = true
}
.disabled(!isEffectivelyOnline)
} else {
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.
/// Thin vertical bar next to an anchored comment's text — same visual
/// language as Word/Google Docs' margin comment indicators. Tapping opens
/// the comments sheet focused to that thread.
private struct CommentAnchorMarker: View {
let rect: CGRect
let onTap: () -> Void
private static let width: CGFloat = 3
private static let gap: CGFloat = 4
private static let hitTargetWidth: CGFloat = 16
var body: some View {
Color.clear
.frame(width: Self.hitTargetWidth, height: max(rect.height, 4))
.contentShape(Rectangle())
.overlay {
RoundedRectangle(cornerRadius: 1.5)
.fill(Color.blue.opacity(0.6))
.frame(width: Self.width)
}
.position(
x: rect.minX - Self.gap - Self.width / 2,
y: rect.minY + rect.height / 2
)
.onTapGesture(perform: onTap)
.help("View comment")
}
}
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)
}
}
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
/// only once the `ImagePlayground` framework is actually linked in Xcode —
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
/// valid to build before that link-up happens.
private struct ImagePlaygroundPresenter: ViewModifier {
@Binding var isPresented: Bool
/// Highlighted document text at the moment the button was pressed, if
/// any — seeds Image Playground's prompt instead of opening blank.
let seedText: String?
/// Document title, used as the concept's title when `seedText` is used.
let seedTitle: String
let onCompletion: (URL) -> Void
func body(content: Content) -> some View {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
let concepts: [ImagePlaygroundConcept] = {
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
}()
content.imagePlaygroundSheet(
isPresented: $isPresented,
concepts: concepts,
onCompletion: { url in
isPresented = false
onCompletion(url)
},
onCancellation: { isPresented = false }
)
} else {
content
}
#else
content
#endif
}
}
#endif