New Outpost-local Settings → Editor section (not synced to Outline, same as Appearance) with a Split View toggle: raw Markdown source on the left (plain TextEditor, not the rendering engine), the same rich rendering used everywhere else in the app on the right, read-only, live-updating off the same text binding. Fixed a real layout bug before shipping it: the split view was nested inside the page-level ScrollView, which proposes unbounded height to its content, so a minHeight just resolved to exactly that minimum instead of filling the window. Restructured so Split View bypasses the outer scroll entirely (title fixed at top, HSplitView taking every remaining pixel below it) — each pane already scrolls itself, so nesting it inside another unbounded scroll container was fighting itself for height. Normal single-pane reading/editing untouched. Known follow-up, not attempted: scroll position between the two panes isn't synchronized — the editor package exposes no scroll hook, so doing this for real means introspecting its private view hierarchy. Also removed the "Sub-documents" section from the reader per explicit request — the childrenSection view, and the now-unnecessary listDocuments(parentDocumentId:) fetch backing it in the view model.
254 lines
9.7 KiB
Swift
254 lines
9.7 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 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.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
|
|
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
|
|
}
|
|
}
|
|
}
|