Drafts + Publish:
- New Home tab backed by documents.drafts (undocumented request shape
confirmed from a live network capture, not the OpenAPI spec).
- Reader toolbar menu now shows Publish... for an unpublished
document instead of an unconditional Unpublish (which could
previously be tapped on a draft at all). Publish opens a
MoveDocumentSheet-style collection/parent picker, pre-filled from
the draft's own collectionId/parentDocumentId when it already has
one. Publishing itself is documents.update(publish: true,
collectionId:) for the collection placement, plus a second
documents.move call only when a specific parent document was also
picked (documents.update has no parentDocumentId field).
- New Document defaults to Draft (collectionId/publish both now
optional on CreateDocumentRequest, previously collectionId was
required so a draft couldn't be created from this sheet at all).
Contextual entry points (right-click a collection/document) still
pre-fill that location, but now show a warning that doing so
auto-publishes.
- Fixed onDeleted only popping the reader's nav path without telling
the sidebar to refresh - Delete/Archive/Unpublish/Move all left the
sidebar showing stale state until an unrelated trigger (the 45s
poll, navigating away and back) happened to catch it up.
Comments:
- Replies (comments.create with parentCommentId, one level of nesting
same as Outline's own limit) and emoji reactions
(comments.add_reaction/remove_reaction, confirmed against Outline's
server source - not in the spec, and return {success: true} rather
than the updated comment, so a toggle refetches via comments.info
for the real post-toggle state) plus a document-level "new comment"
composer, since replying needs something to reply to.
- Inline anchor markers: a new engine-side mechanism
(CommentAnchorQuery/CommentAnchorRect/onCommentAnchorRectsChange)
resolves an anchored comment's anchorText to an on-screen rect via
the same viewRect utility the code-block copy button uses, kept in
sync on typing/resize/reflow the same way the code-block and image
positioning fixes earlier this session are. Renders as a thin blue
bar next to the commented text; tapping it opens the comments sheet
scrolled and highlighted to that thread. First-occurrence text
search only (Outline's API returns no position data, and no
prefix/suffix on read) - creating new anchored comments from this
app still isn't supported.
- Toolbar badge: tighter offset so the count doesn't clip past the
icon, caps at "10+".
261 lines
10 KiB
Swift
261 lines
10 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OutlineKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class DocumentReaderViewModel {
|
|
var title: String
|
|
var emoji: String?
|
|
var text: String
|
|
var collectionId: String?
|
|
var parentDocumentId: String?
|
|
/// `nil` = draft (not published/visible to other workspace members).
|
|
var publishedAt: Date?
|
|
var isFullWidth = false
|
|
var isLoading = false
|
|
var errorMessage: String?
|
|
|
|
var isEditing = false
|
|
var isSaving = false
|
|
var saveErrorMessage: String?
|
|
|
|
/// Snapshot of the preference, set once via `.task` right after the
|
|
/// view appears (can't be read from `@Environment` inside the view's
|
|
/// own `init`) rather than a live binding to `SessionStore` — matches
|
|
/// how `isFullWidth` etc. are already seeded from the document at init
|
|
/// rather than observed reactively. A change made in Settings while a
|
|
/// document is already open takes effect the next document opened, not
|
|
/// mid-session; an acceptable tradeoff for how rarely this gets
|
|
/// toggled versus the complexity of threading a live preference
|
|
/// reference through every reader instance.
|
|
var separateEditingEnabled: Bool
|
|
|
|
/// The single source of truth the view reads for both "show the title
|
|
/// field" and "is the text view editable" — when separate editing is
|
|
/// off there's no Edit/Done mode at all, the document is just always
|
|
/// editable (assuming permission; there's no per-document permission
|
|
/// field to pre-check against, so an unauthorized edit simply fails to
|
|
/// save rather than being blocked client-side up front).
|
|
var isEffectivelyEditable: Bool {
|
|
separateEditingEnabled ? isEditing : true
|
|
}
|
|
|
|
private var autosaveTask: Task<Void, Never>?
|
|
/// Tracks the last known-synced-with-the-server values so
|
|
/// `scheduleAutosave()` can no-op when called just because `text`/
|
|
/// `title` were reassigned *from* a server response (initial load, or
|
|
/// a completed save) rather than actually edited — without this, every
|
|
/// document open in the always-editable mode would fire one pointless
|
|
/// autosave round-trip immediately, re-sending exactly what was just
|
|
/// received.
|
|
private var lastSyncedText: String
|
|
private var lastSyncedTitle: 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?
|
|
|
|
/// `nil` until checked. Inferred from whether `documents.insights`
|
|
/// succeeds or fails — `insightsEnabled` isn't readable back off
|
|
/// `Document` in the vendored spec, so there's no direct field to read.
|
|
/// This is a heuristic, not confirmed server behavior.
|
|
private(set) var isInsightsEnabled: Bool?
|
|
|
|
let documentId: String
|
|
private let apiClient: OutlineAPIClient
|
|
|
|
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
|
self.apiClient = apiClient
|
|
self.documentId = document.id
|
|
self.title = document.title
|
|
self.emoji = document.emoji
|
|
self.text = document.text
|
|
self.collectionId = document.collectionId
|
|
self.parentDocumentId = document.parentDocumentId
|
|
self.publishedAt = document.publishedAt
|
|
self.isFullWidth = document.fullWidth ?? false
|
|
self.separateEditingEnabled = separateEditingEnabled
|
|
self.lastSyncedText = document.text
|
|
self.lastSyncedTitle = document.title
|
|
}
|
|
|
|
/// The list endpoint's copy of a document isn't guaranteed to be the full,
|
|
/// current body — always re-fetch via `documents.info` when actually opened.
|
|
func loadFullContent() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
|
|
do {
|
|
let full = try await apiClient.documentInfo(id: documentId)
|
|
title = full.title
|
|
emoji = full.emoji
|
|
text = full.text
|
|
collectionId = full.collectionId
|
|
parentDocumentId = full.parentDocumentId
|
|
publishedAt = full.publishedAt
|
|
isFullWidth = full.fullWidth ?? false
|
|
lastSyncedText = full.text
|
|
lastSyncedTitle = full.title
|
|
} catch {
|
|
errorMessage = "Couldn't load this document. Check your connection and try again."
|
|
}
|
|
}
|
|
|
|
func loadViewers() async {
|
|
guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return }
|
|
viewers = views.filter { $0.lastViewedAt != nil }
|
|
}
|
|
|
|
func loadPinAndSubscriptionState() async {
|
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
|
// Home", not "Pin to Collection" — those are distinct on the server.
|
|
if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)),
|
|
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 loadInsightsEnabledState() async {
|
|
do {
|
|
_ = try await apiClient.documentInsights(DocumentInsightsRequest(id: documentId))
|
|
isInsightsEnabled = true
|
|
} catch {
|
|
isInsightsEnabled = false
|
|
}
|
|
}
|
|
|
|
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: nil))
|
|
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
|
|
}
|
|
}
|
|
|
|
/// Turning editing off saves; turning it on is just a mode switch. Only
|
|
/// meaningful when `separateEditingEnabled` — the always-editable path
|
|
/// uses `scheduleAutosave()` instead.
|
|
func toggleEditing() async {
|
|
guard isEditing else {
|
|
isEditing = true
|
|
return
|
|
}
|
|
await save()
|
|
if saveErrorMessage == nil {
|
|
isEditing = false
|
|
}
|
|
}
|
|
|
|
/// Debounced save for the always-editable (separate editing off) path —
|
|
/// cancels any pending save and starts a fresh countdown on every call,
|
|
/// so a save only actually fires once typing pauses, not on every
|
|
/// keystroke. Goes through the same `updateDocument` call the explicit
|
|
/// Done-button save uses, which is already offline-queue-aware
|
|
/// (`CachingOutlineAPIClient`), so autosave while offline just queues
|
|
/// like any other edit instead of needing separate handling here.
|
|
func scheduleAutosave() {
|
|
guard text != lastSyncedText || title != lastSyncedTitle else { return }
|
|
autosaveTask?.cancel()
|
|
autosaveTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
guard let self, !Task.isCancelled else { return }
|
|
await self.save()
|
|
}
|
|
}
|
|
|
|
private func save() async {
|
|
isSaving = true
|
|
saveErrorMessage = nil
|
|
defer { isSaving = false }
|
|
let sentTitle = title
|
|
let sentText = text
|
|
do {
|
|
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
|
|
// Only reconcile with the server's response if nothing changed
|
|
// locally while the request was in flight — otherwise this
|
|
// would clobber keystrokes typed during a debounced autosave's
|
|
// round trip. Whatever's newer goes out on the next autosave
|
|
// cycle regardless, since `scheduleAutosave()` keeps getting
|
|
// re-triggered by continued typing.
|
|
if title == sentTitle { title = updated.title }
|
|
if text == sentText { text = updated.text }
|
|
lastSyncedTitle = sentTitle
|
|
lastSyncedText = sentText
|
|
} 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
|
|
}
|
|
}
|
|
|
|
func toggleViewerInsights() async throws {
|
|
let newValue = !(isInsightsEnabled ?? false)
|
|
isInsightsEnabled = newValue
|
|
do {
|
|
_ = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, insightsEnabled: newValue))
|
|
} catch {
|
|
isInsightsEnabled = !newValue
|
|
throw error
|
|
}
|
|
}
|
|
}
|