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.
1038 lines
45 KiB
Swift
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 `` 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)\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
|