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:
@@ -28,6 +28,19 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision]
|
func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision]
|
||||||
/// Returns the document's markdown source directly (not a file operation job). Backed by `documents.export`.
|
/// Returns the document's markdown source directly (not a file operation job). Backed by `documents.export`.
|
||||||
func exportDocument(id: String) async throws -> String
|
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 listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection]
|
||||||
func collectionInfo(id: String) 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))
|
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] {
|
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
||||||
try await post("collections.list", body: CollectionListParams(offset: offset, limit: limit))
|
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 parentDocumentId: String?
|
||||||
public let url: String
|
public let url: String
|
||||||
public let revision: Int?
|
public let revision: Int?
|
||||||
|
public let fullWidth: Bool?
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
public let publishedAt: Date?
|
public let publishedAt: Date?
|
||||||
@@ -24,6 +25,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
|
|||||||
parentDocumentId: String? = nil,
|
parentDocumentId: String? = nil,
|
||||||
url: String,
|
url: String,
|
||||||
revision: Int? = nil,
|
revision: Int? = nil,
|
||||||
|
fullWidth: Bool? = nil,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
publishedAt: Date? = nil,
|
publishedAt: Date? = nil,
|
||||||
@@ -38,6 +40,7 @@ public struct OutlineDocument: Codable, Identifiable, Hashable, Sendable {
|
|||||||
self.parentDocumentId = parentDocumentId
|
self.parentDocumentId = parentDocumentId
|
||||||
self.url = url
|
self.url = url
|
||||||
self.revision = revision
|
self.revision = revision
|
||||||
|
self.fullWidth = fullWidth
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.publishedAt = publishedAt
|
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 title: String?
|
||||||
public let text: String?
|
public let text: String?
|
||||||
public let append: Bool?
|
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(
|
public init(
|
||||||
id: String,
|
id: String,
|
||||||
title: String? = nil,
|
title: String? = nil,
|
||||||
text: String? = nil,
|
text: String? = nil,
|
||||||
append: Bool? = nil
|
append: Bool? = nil,
|
||||||
|
fullWidth: Bool? = nil,
|
||||||
|
insightsEnabled: Bool? = nil,
|
||||||
|
documentEmbeds: Bool? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
self.text = text
|
self.text = text
|
||||||
self.append = append
|
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")
|
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 {
|
func testMissingTokenThrowsTokenUnavailable() async throws {
|
||||||
let httpClient = MockHTTPClient()
|
let httpClient = MockHTTPClient()
|
||||||
let client = LiveOutlineAPIClient(
|
let client = LiveOutlineAPIClient(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ struct ContentView_macOS: View {
|
|||||||
@State private var documentPath: [OutlineDocument] = []
|
@State private var documentPath: [OutlineDocument] = []
|
||||||
@State private var globalSearchQuery = ""
|
@State private var globalSearchQuery = ""
|
||||||
@State private var contextualSearchQuery = ""
|
@State private var contextualSearchQuery = ""
|
||||||
|
@State private var isContextualSearchExpanded = false
|
||||||
@FocusState private var isContextualSearchFocused: Bool
|
@FocusState private var isContextualSearchFocused: Bool
|
||||||
|
|
||||||
private var trimmedGlobalQuery: String {
|
private var trimmedGlobalQuery: String {
|
||||||
@@ -45,16 +46,56 @@ struct ContentView_macOS: View {
|
|||||||
ToolbarItem(placement: .navigation) {
|
ToolbarItem(placement: .navigation) {
|
||||||
leadingToolbarContent
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
.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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 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"
|
|
||||||
)
|
|
||||||
.searchFocused($isContextualSearchFocused)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -123,6 +164,7 @@ struct ContentView_macOS: View {
|
|||||||
private func searchInCollection(_ collection: OutlineCollection) {
|
private func searchInCollection(_ collection: OutlineCollection) {
|
||||||
globalSearchQuery = ""
|
globalSearchQuery = ""
|
||||||
selectedCollection = collection
|
selectedCollection = collection
|
||||||
|
isContextualSearchExpanded = true
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
isContextualSearchFocused = true
|
isContextualSearchFocused = true
|
||||||
}
|
}
|
||||||
@@ -182,7 +224,12 @@ struct ContentView_macOS: View {
|
|||||||
DocumentReaderView(
|
DocumentReaderView(
|
||||||
apiClient: apiClient,
|
apiClient: apiClient,
|
||||||
document: document,
|
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)
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Read-only (`isEditable: false`), matching the Overview tab — document editing
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||||
/// isn't wired up yet, but link clicks and text selection still work.
|
/// below must run on the main thread — see the identical note on
|
||||||
|
/// `DocumentNodeRow` in `CollectionDocumentsOutline.swift`.
|
||||||
|
@MainActor
|
||||||
struct DocumentReaderView: View {
|
struct DocumentReaderView: View {
|
||||||
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(StarStore.self) private var starStore
|
||||||
|
|
||||||
@State private var viewModel: DocumentReaderViewModel
|
@State private var viewModel: DocumentReaderViewModel
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
|
let document: OutlineDocument
|
||||||
/// Pushes a genuine new stack entry (not a reset+replace) — the back
|
/// Pushes a genuine new stack entry (not a reset+replace) — the back
|
||||||
/// button then correctly returns to this document, not the collection.
|
/// button then correctly returns to this document, not the collection.
|
||||||
let onOpenChild: (OutlineDocument) -> Void
|
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))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
|
self.onDeleted = onDeleted
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
// No in-content header — the document's icon/title live in the
|
if viewModel.isEditing {
|
||||||
// window toolbar now (via `ContentView_macOS`).
|
TextField("Title", text: $viewModel.title)
|
||||||
|
.font(.largeTitle.weight(.bold))
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.isLoading && viewModel.text.isEmpty {
|
if viewModel.isLoading && viewModel.text.isEmpty {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
@@ -38,7 +74,7 @@ struct DocumentReaderView: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(heightBehavior: .fitsContent),
|
configuration: .init(heightBehavior: .fitsContent),
|
||||||
isEditable: false
|
isEditable: viewModel.isEditing
|
||||||
)
|
)
|
||||||
|
|
||||||
if !viewModel.children.isEmpty {
|
if !viewModel.children.isEmpty {
|
||||||
@@ -47,12 +83,9 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.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) {
|
.overlay(alignment: .topTrailing) {
|
||||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -60,7 +93,130 @@ struct DocumentReaderView: View {
|
|||||||
.padding(12)
|
.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.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 {
|
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
|
#endif
|
||||||
|
|||||||
@@ -8,10 +8,28 @@ final class DocumentReaderViewModel {
|
|||||||
var title: String
|
var title: String
|
||||||
var emoji: String?
|
var emoji: String?
|
||||||
var text: String
|
var text: String
|
||||||
|
var collectionId: String?
|
||||||
|
var isFullWidth = false
|
||||||
var children: [OutlineDocument] = []
|
var children: [OutlineDocument] = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
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
|
let documentId: String
|
||||||
private let apiClient: OutlineAPIClient
|
private let apiClient: OutlineAPIClient
|
||||||
|
|
||||||
@@ -21,6 +39,8 @@ final class DocumentReaderViewModel {
|
|||||||
self.title = document.title
|
self.title = document.title
|
||||||
self.emoji = document.emoji
|
self.emoji = document.emoji
|
||||||
self.text = document.text
|
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,
|
/// 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
|
title = full.title
|
||||||
emoji = full.emoji
|
emoji = full.emoji
|
||||||
text = full.text
|
text = full.text
|
||||||
|
collectionId = full.collectionId
|
||||||
|
isFullWidth = full.fullWidth ?? false
|
||||||
} catch {
|
} catch {
|
||||||
errorMessage = "Couldn't load this document. Check your connection and try again."
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
||||||
}
|
}
|
||||||
@@ -46,4 +68,114 @@ final class DocumentReaderViewModel {
|
|||||||
limit: 100
|
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
|
||||||
Reference in New Issue
Block a user