Files
Outpost/Outpost/Features/Collections/DocumentReaderViewModel.swift
T
Puranjay Savar Mattas bdde8642f7 feat(editor): Split View (raw Markdown / live preview), remove Sub-documents
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.
2026-08-19 15:42:35 +01:00

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
}
}
}