feat(reader): document toolbar - viewers, share, edit, pin/subscribe, menu

Adds the full document reader toolbar: recent-viewer avatar stack
(views.list, polled every 20s, filtered to entries with a
lastViewedAt), Share button (shares.create/info/update), Edit/Done
toggle that switches the reader into an editable NativeTextViewWrapper
and saves via documents.update (last-write-wins REST editing, per
CLAUDE.md's Phase 1 scope - full realtime CRDT collaboration is a
separate, much larger effort, see the Collaboration/ skeleton in this
branch), New Document button, and an overflow menu with the same
action set as the sidebar's document context menu plus this session's
additions: Star/Unstar (shared StarStore), Pin/Unpin and
Subscribe/Unsubscribe (pins.*/subscriptions.* - not in the vendored
OpenAPI spec, reconstructed from Outline's actual API surface and
flagged as best-effort in code comments), Enable Viewer Insights,
Enable Embeds (speculative field on documents.update), and Full
Width (toggles + reads back Document.fullWidth, now added to the
model).

Also replaces the always-expanded .searchable contextual search field
with a plain icon that expands into a field on click.

OutlineKit: shares.*, pins.* (best-effort), subscriptions.*
(best-effort), views.list, with test coverage for the endpoints that
are in the vendored spec.
This commit is contained in:
2026-08-14 02:53:54 +01:00
parent e8a72014b9
commit 86e7aa5800
21 changed files with 1029 additions and 22 deletions
@@ -28,6 +28,19 @@ public protocol OutlineAPIClient: Sendable {
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision]
/// Returns the document's markdown source directly (not a file operation job). Backed by `documents.export`.
func exportDocument(id: String) async throws -> String
func createShare(_ request: CreateShareRequest) async throws -> OutlineShare
func shareInfo(documentId: String) async throws -> OutlineShare?
func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare
/// See `OutlinePin` best-effort, not in the vendored spec.
func createPin(_ request: CreatePinRequest) async throws -> OutlinePin
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin]
func deletePin(id: String) async throws
/// See `OutlineSubscription` best-effort, not in the vendored spec.
func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription
func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription]
func deleteSubscription(id: String) async throws
/// Historical view records, not live presence. Backed by `views.list`.
func listViews(_ request: ListViewsRequest) async throws -> [OutlineView]
func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
func collectionInfo(id: String) async throws -> OutlineCollection
@@ -108,6 +108,50 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
try await post("documents.export", body: DocumentIDRequest(id: id))
}
public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare {
try await post("shares.create", body: request)
}
public func shareInfo(documentId: String) async throws -> OutlineShare? {
do {
return try await post("shares.info", body: ShareInfoRequest(documentId: documentId))
} catch OutlineAPIError.notFound {
return nil
}
}
public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare {
try await post("shares.update", body: request)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
try await post("pins.create", body: request)
}
public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await post("pins.list", body: request)
}
public func deletePin(id: String) async throws {
try await postForSuccess("pins.delete", body: StarIDParams(id: id))
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
try await post("subscriptions.create", body: request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await post("subscriptions.list", body: request)
}
public func deleteSubscription(id: String) async throws {
try await postForSuccess("subscriptions.delete", body: StarIDParams(id: id))
}
public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] {
try await post("views.list", body: request)
}
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
}
@@ -9,6 +9,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
public let parentDocumentId: String?
public let url: String
public let revision: Int?
public let fullWidth: Bool?
public let createdAt: Date
public let updatedAt: Date
public let publishedAt: Date?
@@ -24,6 +25,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
parentDocumentId: String? = nil,
url: String,
revision: Int? = nil,
fullWidth: Bool? = nil,
createdAt: Date,
updatedAt: Date,
publishedAt: Date? = nil,
@@ -38,6 +40,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
self.parentDocumentId = parentDocumentId
self.url = url
self.revision = revision
self.fullWidth = fullWidth
self.createdAt = createdAt
self.updatedAt = updatedAt
self.publishedAt = publishedAt
@@ -0,0 +1,14 @@
import Foundation
/// A document pinned to the top of a collection (or the team home), backed by
/// `pins.*`. Not in the vendored OpenAPI spec (`docs/reference/outline-openapi`)
/// that spec has no `Pins` tag at all but the endpoint exists on Outline's
/// actual server (`server/routes/api/pins.ts` upstream). Shape reconstructed
/// from general knowledge of Outline's API, not verified against this spec;
/// treat field names as best-effort until confirmed against a live server.
public struct OutlinePin: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
public let collectionId: String?
public let index: String?
}
@@ -0,0 +1,10 @@
import Foundation
/// A public share link for a document or collection, backed by `shares.*`.
public struct OutlineShare: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let collectionId: String?
public let url: String
public let published: Bool
}
@@ -0,0 +1,15 @@
import Foundation
/// A notification subscription on a document (or collection), backed by
/// `subscriptions.*`. Same caveat as `OutlinePin`: not in the vendored
/// OpenAPI spec no `Subscriptions` tag exists there but the endpoint
/// exists on Outline's actual server (`server/routes/api/subscriptions.ts`
/// upstream). Shape reconstructed from general knowledge, not verified
/// against this spec; treat field names as best-effort until confirmed
/// against a live server.
public struct OutlineSubscription: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String?
public let collectionId: String?
public let event: String
}
@@ -0,0 +1,15 @@
import Foundation
/// A record of a user having viewed a document, backed by `views.list`.
/// This is historical/aggregated (`firstViewedAt`/`lastViewedAt`/`count`),
/// not live presence there's no realtime "viewing right now" signal over
/// REST, only over the Hocuspocus collaboration socket's awareness protocol.
public struct OutlineView: Decodable, Identifiable, Sendable {
public let id: String
public let documentId: String
public let userId: String
public let user: OutlineUser
public let count: Int
public let firstViewedAt: Date?
public let lastViewedAt: Date?
}
@@ -0,0 +1,12 @@
import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec.
public struct CreatePinRequest: Encodable, Sendable {
public let documentId: String
public let collectionId: String?
public init(documentId: String, collectionId: String? = nil) {
self.documentId = documentId
self.collectionId = collectionId
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct CreateShareRequest: Encodable, Sendable {
public let documentId: String?
public let collectionId: String?
public init(documentId: String? = nil, collectionId: String? = nil) {
self.documentId = documentId
self.collectionId = collectionId
}
}
@@ -0,0 +1,12 @@
import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct CreateSubscriptionRequest: Encodable, Sendable {
public let documentId: String
public let event: String
public init(documentId: String, event: String = "documents.update") {
self.documentId = documentId
self.event = event
}
}
@@ -0,0 +1,10 @@
import Foundation
/// See `OutlinePin` best-effort shape, not in the vendored spec.
public struct ListPinsRequest: Encodable, Sendable {
public let collectionId: String?
public init(collectionId: String? = nil) {
self.collectionId = collectionId
}
}
@@ -0,0 +1,12 @@
import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct ListSubscriptionsRequest: Encodable, Sendable {
public let documentId: String
public let event: String
public init(documentId: String, event: String = "documents.update") {
self.documentId = documentId
self.event = event
}
}
@@ -0,0 +1,9 @@
import Foundation
public struct ListViewsRequest: Encodable, Sendable {
public let documentId: String
public init(documentId: String) {
self.documentId = documentId
}
}
@@ -0,0 +1,9 @@
import Foundation
public struct ShareInfoRequest: Encodable, Sendable {
public let documentId: String?
public init(documentId: String?) {
self.documentId = documentId
}
}
@@ -5,16 +5,30 @@ public struct UpdateDocumentRequest: Encodable, Sendable {
public let title: String?
public let text: String?
public let append: Bool?
public let fullWidth: Bool?
public let insightsEnabled: Bool?
/// Not in the vendored spec's `Document`/`documents.update` shape at all
/// (only a workspace-level `documentEmbeds` flag exists there) included
/// speculatively since the field may exist on newer self-hosted servers.
/// Unrecognized fields are typically ignored server-side rather than
/// rejected, so this is low-risk even if unsupported.
public let documentEmbeds: Bool?
public init(
id: String,
title: String? = nil,
text: String? = nil,
append: Bool? = nil
append: Bool? = nil,
fullWidth: Bool? = nil,
insightsEnabled: Bool? = nil,
documentEmbeds: Bool? = nil
) {
self.id = id
self.title = title
self.text = text
self.append = append
self.fullWidth = fullWidth
self.insightsEnabled = insightsEnabled
self.documentEmbeds = documentEmbeds
}
}
@@ -0,0 +1,11 @@
import Foundation
public struct UpdateShareRequest: Encodable, Sendable {
public let id: String
public let published: Bool
public init(id: String, published: Bool) {
self.id = id
self.published = published
}
}
@@ -584,6 +584,116 @@ final class LiveOutlineAPIClientTests: XCTestCase {
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/stars.delete")
}
func testCreateShareDecodesShare() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": {
"id": "share-1",
"documentId": "doc-1",
"collectionId": null,
"url": "https://outline.example.com/s/share-1",
"published": false
}
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.createShare(CreateShareRequest(documentId: "doc-1"))
XCTAssertEqual(share.url, "https://outline.example.com/s/share-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/shares.create")
}
func testShareInfoReturnsNilOnNotFound() async throws {
let httpClient = MockHTTPClient()
httpClient.statusCode = 404
httpClient.responseData = """
{ "ok": false, "error": "not_found", "message": "Not Found" }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let share = try await client.shareInfo(documentId: "doc-1")
XCTAssertNil(share)
}
func testListViewsDecodesViewsAndFiltersNilLastViewedAt() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{
"data": [
{
"id": "view-1",
"documentId": "doc-1",
"userId": "user-1",
"user": { "id": "user-1", "name": "Jane Doe" },
"count": 3,
"firstViewedAt": "2026-01-01T00:00:00.000Z",
"lastViewedAt": "2026-01-02T00:00:00.000Z"
}
]
}
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let views = try await client.listViews(ListViewsRequest(documentId: "doc-1"))
XCTAssertEqual(views.first?.user.name, "Jane Doe")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/views.list")
}
func testCreatePinDecodesPin() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "pin-1", "documentId": "doc-1", "collectionId": "col-1", "index": "a" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let pin = try await client.createPin(CreatePinRequest(documentId: "doc-1", collectionId: "col-1"))
XCTAssertEqual(pin.id, "pin-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/pins.create")
}
func testCreateSubscriptionDecodesSubscription() async throws {
let httpClient = MockHTTPClient()
httpClient.responseData = """
{ "data": { "id": "sub-1", "documentId": "doc-1", "collectionId": null, "event": "documents.update" } }
""".data(using: .utf8)!
let client = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: URL(string: "https://outline.example.com")!),
tokenStore: StaticTokenStore(),
httpClient: httpClient
)
let subscription = try await client.createSubscription(CreateSubscriptionRequest(documentId: "doc-1"))
XCTAssertEqual(subscription.id, "sub-1")
XCTAssertEqual(httpClient.lastRequest?.url?.path, "/api/subscriptions.create")
}
func testMissingTokenThrowsTokenUnavailable() async throws {
let httpClient = MockHTTPClient()
let client = LiveOutlineAPIClient(
@@ -10,6 +10,7 @@ struct ContentView_macOS: View {
@State private var documentPath: [OutlineDocument] = []
@State private var globalSearchQuery = ""
@State private var contextualSearchQuery = ""
@State private var isContextualSearchExpanded = false
@FocusState private var isContextualSearchFocused: Bool
private var trimmedGlobalQuery: String {
@@ -45,16 +46,56 @@ struct ContentView_macOS: View {
ToolbarItem(placement: .navigation) {
leadingToolbarContent
}
// Custom instead of `.searchable`: that modifier always renders a
// full-width field, but this is meant to sit alongside the other
// per-document toolbar buttons as a plain icon that only expands
// into a field once clicked.
ToolbarItem(placement: .primaryAction) {
contextualSearchField
}
// Attached at this level (not inside the detail's NavigationStack) so
// it reliably renders in the shared window toolbar instead of a
// per-screen one that can get lost when nested more deeply.
.searchable(
text: $contextualSearchQuery,
placement: .toolbar,
prompt: selectedCollection.map { "Search \($0.name)" } ?? "Search"
}
}
@ViewBuilder
private var contextualSearchField: some View {
if isContextualSearchExpanded || !contextualSearchQuery.isEmpty {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(
selectedCollection.map { "Search \($0.name)" } ?? "Search",
text: $contextualSearchQuery
)
.searchFocused($isContextualSearchFocused)
.textFieldStyle(.plain)
.focused($isContextualSearchFocused)
.frame(width: 180)
if !contextualSearchQuery.isEmpty {
Button {
contextualSearchQuery = ""
} label: {
Image(systemName: "xmark.circle.fill")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
.onChange(of: isContextualSearchFocused) { _, focused in
if !focused && contextualSearchQuery.isEmpty {
isContextualSearchExpanded = false
}
}
} else {
Button {
isContextualSearchExpanded = true
Task { @MainActor in isContextualSearchFocused = true }
} label: {
Image(systemName: "magnifyingglass")
}
}
}
@ViewBuilder
@@ -123,6 +164,7 @@ struct ContentView_macOS: View {
private func searchInCollection(_ collection: OutlineCollection) {
globalSearchQuery = ""
selectedCollection = collection
isContextualSearchExpanded = true
Task { @MainActor in
isContextualSearchFocused = true
}
@@ -182,7 +224,12 @@ struct ContentView_macOS: View {
DocumentReaderView(
apiClient: apiClient,
document: document,
onOpenChild: { child in documentPath.append(child) }
onOpenChild: { child in documentPath.append(child) },
onDeleted: {
if !documentPath.isEmpty {
documentPath.removeLast()
}
}
)
}
}
@@ -1,26 +1,62 @@
#if os(macOS)
import AppKit
import SwiftUI
import UniformTypeIdentifiers
import MarkdownEngine
import OutlineKit
/// Read-only (`isEditable: false`), matching the Overview tab document editing
/// isn't wired up yet, but link clicks and text selection still work.
/// `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
init(apiClient: OutlineAPIClient, document: OutlineDocument, onOpenChild: @escaping (OutlineDocument) -> 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 actionErrorMessage: String?
init(
apiClient: OutlineAPIClient,
document: OutlineDocument,
onOpenChild: @escaping (OutlineDocument) -> Void,
onDeleted: @escaping () -> Void
) {
self.apiClient = apiClient
self.document = document
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 12) {
// No in-content header the document's icon/title live in the
// window toolbar now (via `ContentView_macOS`).
if viewModel.isEditing {
TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold))
.textFieldStyle(.plain)
}
if viewModel.isLoading && viewModel.text.isEmpty {
ProgressView()
.frame(maxWidth: .infinity)
@@ -38,7 +74,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent),
isEditable: false
isEditable: viewModel.isEditing
)
if !viewModel.children.isEmpty {
@@ -47,12 +83,9 @@ struct DocumentReaderView: View {
}
}
.padding()
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
.frame(maxWidth: .infinity)
}
// No `.navigationTitle` here either same reason as CollectionOverviewView.
// Corner spinner, not gated on `text.isEmpty`: `viewModel.text` starts
// pre-filled from the list/summary copy of the doc, so the full-page
// spinner above rarely fires on open without this, the re-fetch in
// `loadFullContent()` looked like nothing was happening.
.overlay(alignment: .topTrailing) {
if viewModel.isLoading && !viewModel.text.isEmpty {
ProgressView()
@@ -60,7 +93,130 @@ struct DocumentReaderView: View {
.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 {
Task { await createChildDocument() }
} label: {
Image(systemName: "doc.badge.plus")
}
.help("New Document")
Menu {
menuContent
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.task { await viewModel.loadFullContent() }
.task {
await viewModel.loadPinAndSubscriptionState()
}
.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)
}
}
@ViewBuilder
private var viewerAvatars: some View {
if !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 {
@@ -87,5 +243,241 @@ struct DocumentReaderView: View {
}
}
}
@ViewBuilder
private var menuContent: some View {
Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") {
Task { await star() }
}
Button(viewModel.isSubscribed ? "Unsubscribe" : "Subscribe") {
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") {
Task { await createChildDocument() }
}
Button(viewModel.isPinned ? "Unpin" : "Pin") {
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()
Button("Enable Viewer Insights") {
Task { await enableInsights() }
}
Button("Enable Embeds") {
Task { await enableEmbeds() }
}
Button(viewModel.isFullWidth ? "Default Width" : "Full Width") {
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 enableInsights() async {
do {
try await viewModel.enableViewerInsights()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable viewer insights.")
}
}
private func enableEmbeds() async {
do {
try await viewModel.enableEmbeds()
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't enable embeds.")
}
}
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 createChildDocument() async {
guard let collectionId = viewModel.collectionId else {
actionErrorMessage = "This document isn't in a collection."
return
}
do {
let child = try await apiClient.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: collectionId, parentDocumentId: viewModel.documentId)
)
onOpenChild(child)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't create a new 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
@@ -8,10 +8,28 @@ final class DocumentReaderViewModel {
var title: String
var emoji: String?
var text: String
var collectionId: String?
var isFullWidth = false
var children: [OutlineDocument] = []
var isLoading = false
var errorMessage: String?
var isEditing = false
var isSaving = false
var saveErrorMessage: String?
/// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration
/// socket's awareness protocol, which isn't wired up yet).
private(set) var viewers: [OutlineView] = []
private(set) var isPinned = false
private var pinId: String?
private(set) var isSubscribed = false
private var subscriptionId: String?
private(set) var share: OutlineShare?
let documentId: String
private let apiClient: OutlineAPIClient
@@ -21,6 +39,8 @@ final class DocumentReaderViewModel {
self.title = document.title
self.emoji = document.emoji
self.text = document.text
self.collectionId = document.collectionId
self.isFullWidth = document.fullWidth ?? false
}
/// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -35,6 +55,8 @@ final class DocumentReaderViewModel {
title = full.title
emoji = full.emoji
text = full.text
collectionId = full.collectionId
isFullWidth = full.fullWidth ?? false
} catch {
errorMessage = "Couldn't load this document. Check your connection and try again."
}
@@ -46,4 +68,114 @@ final class DocumentReaderViewModel {
limit: 100
)) ?? []
}
func loadViewers() async {
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
viewers = views.filter { $0.lastViewedAt != nil }
}
func loadPinAndSubscriptionState() async {
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collectionId)),
let match = pins.first(where: { $0.documentId == documentId }) {
isPinned = true
pinId = match.id
} else {
isPinned = false
pinId = nil
}
if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)),
let match = subscriptions.first {
isSubscribed = true
subscriptionId = match.id
} else {
isSubscribed = false
subscriptionId = nil
}
}
func loadShare() async {
share = try? await apiClient.shareInfo(documentId: documentId)
}
func togglePin() async throws {
if let pinId {
self.pinId = nil
isPinned = false
do {
try await apiClient.deletePin(id: pinId)
} catch {
self.pinId = pinId
isPinned = true
throw error
}
} else {
let pin = try await apiClient.createPin(CreatePinRequest(documentId: documentId, collectionId: collectionId))
pinId = pin.id
isPinned = true
}
}
func toggleSubscription() async throws {
if let subscriptionId {
self.subscriptionId = nil
isSubscribed = false
do {
try await apiClient.deleteSubscription(id: subscriptionId)
} catch {
self.subscriptionId = subscriptionId
isSubscribed = true
throw error
}
} else {
let subscription = try await apiClient.createSubscription(CreateSubscriptionRequest(documentId: documentId))
subscriptionId = subscription.id
isSubscribed = true
}
}
func createOrLoadShare() async throws {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
}
/// Turning editing off saves; turning it on is just a mode switch.
func toggleEditing() async {
guard isEditing else {
isEditing = true
return
}
isSaving = true
saveErrorMessage = nil
defer { isSaving = false }
do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text))
title = updated.title
text = updated.text
isEditing = false
} catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
}
}
func toggleFullWidth() async throws {
let newValue = !isFullWidth
isFullWidth = newValue
do {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, fullWidth: newValue))
} catch {
isFullWidth = !newValue
throw error
}
}
/// Fire-and-forget: `insightsEnabled` isn't readable back off `Document`
/// in the vendored spec, so there's no state to reflect as a checkmark.
func enableViewerInsights() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: true))
}
/// Fire-and-forget, speculative field see `UpdateDocumentRequest.documentEmbeds`.
func enableEmbeds() async throws {
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, documentEmbeds: true))
}
}
@@ -0,0 +1,112 @@
#if os(macOS)
import AppKit
import SwiftUI
import OutlineKit
@MainActor
struct DocumentShareSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let documentId: String
@State private var share: OutlineShare?
@State private var isLoading = false
@State private var isUpdating = false
@State private var errorMessage: String?
@State private var didCopy = false
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Share")
.font(.headline)
Spacer()
Button("Done") { dismiss() }
}
if isLoading {
ProgressView().frame(maxWidth: .infinity)
} else if let errorMessage {
Text(errorMessage)
.font(.callout)
.foregroundStyle(.red)
} else if let share {
HStack {
Text(share.url)
.font(.callout)
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button {
copyLink(share.url)
} label: {
Image(systemName: didCopy ? "checkmark" : "doc.on.doc")
}
.buttonStyle(.plain)
}
.padding(8)
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 6))
Toggle(
"Published — accessible without sign-in",
isOn: Binding(
get: { share.published },
set: { newValue in Task { await setPublished(newValue) } }
)
)
.disabled(isUpdating)
} else {
Button("Create Share Link") {
Task { await create() }
}
}
}
.padding(20)
.frame(width: 380)
.task { await load() }
}
private func copyLink(_ url: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(url, forType: .string)
didCopy = true
Task {
try? await Task.sleep(for: .seconds(1.5))
didCopy = false
}
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
share = try await apiClient.shareInfo(documentId: documentId)
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load sharing info.")
}
}
private func create() async {
isLoading = true
defer { isLoading = false }
do {
share = try await apiClient.createShare(CreateShareRequest(documentId: documentId))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't create a share link.")
}
}
private func setPublished(_ published: Bool) async {
guard let share else { return }
isUpdating = true
defer { isUpdating = false }
do {
self.share = try await apiClient.updateShare(UpdateShareRequest(id: share.id, published: published))
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't update this share link.")
}
}
}
#endif