The reader's inline "New Document" button and Home's New Document sheet only navigated to the new document — neither had a handle on the sidebar row it landed under, so the tree stayed stale until the periodic remote-changes poll (45s) surfaced the "reload" banner. New Document flows that already have a direct handle on their own sidebar row (right-click a collection, right-click a document) already refreshed correctly and are untouched. Threads a documentsChangedToken from ContentView_macOS down through CollectionsTreeView -> CollectionTreeRow -> CollectionDocumentsOutline as externalRefreshToken; every expanded row reloads itself when it bumps, since neither Home nor the reader knows which row (if any) corresponds to where the new document landed.
507 lines
18 KiB
Swift
507 lines
18 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import MarkdownEngine
|
|
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
|
|
|
|
@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?
|
|
|
|
init(
|
|
apiClient: OutlineAPIClient,
|
|
document: OutlineDocument,
|
|
onOpenChild: @escaping (OutlineDocument) -> Void,
|
|
onDeleted: @escaping () -> Void,
|
|
onDocumentCreated: @escaping () -> Void
|
|
) {
|
|
self.apiClient = apiClient
|
|
self.document = document
|
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
|
self.onOpenChild = onOpenChild
|
|
self.onDeleted = onDeleted
|
|
self.onDocumentCreated = onDocumentCreated
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if viewModel.isEditing {
|
|
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 {
|
|
NativeTextViewWrapper(
|
|
text: $viewModel.text,
|
|
configuration: .init(heightBehavior: .fitsContent),
|
|
isEditable: viewModel.isEditing
|
|
)
|
|
|
|
if !viewModel.children.isEmpty {
|
|
childrenSection
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.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("Share")
|
|
|
|
Button {
|
|
Task { await viewModel.toggleEditing() }
|
|
} label: {
|
|
if viewModel.isSaving {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Text(viewModel.isEditing ? "Done" : "Edit")
|
|
}
|
|
}
|
|
.disabled(viewModel.isSaving)
|
|
|
|
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() }
|
|
.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: $isShowingShareSheet) {
|
|
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
|
}
|
|
.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
|
|
].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)
|
|
}
|
|
}
|
|
|
|
private var childrenSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Divider()
|
|
.padding(.vertical, 4)
|
|
|
|
Text("Sub-documents")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
ForEach(viewModel.children) { child in
|
|
Button {
|
|
onOpenChild(child)
|
|
} label: {
|
|
DocumentRowView(document: child)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.vertical, 4)
|
|
|
|
if child.id != viewModel.children.last?.id {
|
|
Divider()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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()
|
|
|
|
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
|
Task { await viewModel.toggleEditing() }
|
|
}
|
|
// Sharing/membership management is its own subsystem, not a one-off
|
|
// action — deferred rather than half-built here.
|
|
Button("Permissions…") {}
|
|
.disabled(true)
|
|
|
|
Divider()
|
|
|
|
Button("Templatize") {
|
|
Task { await templatize() }
|
|
}
|
|
Button("Duplicate") {
|
|
Task { await duplicate() }
|
|
}
|
|
Button("Unpublish") {
|
|
isShowingUnpublishConfirmation = true
|
|
}
|
|
Button("Archive…") {
|
|
isShowingArchiveConfirmation = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Move") {
|
|
isShowingMoveSheet = true
|
|
}
|
|
// 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
|
|
}
|
|
Button("Insights") {
|
|
isShowingInsightsSheet = true
|
|
}
|
|
Button("Present") {
|
|
isShowingPresentSheet = true
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Download") {
|
|
Task { await download() }
|
|
}
|
|
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() } }
|
|
))
|
|
// 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
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
#endif
|