feat(editor): wire up Separate Editing preference

Preferences → Separate Editing now actually drives document reader
behavior, not just a saved-but-inert server setting:

- On (default, today's existing behavior): unchanged — explicit
  Edit/Done toggle, save on Done.
- Off: no Edit/Done affordance — document is always editable directly
  (no per-document permission field exists server-side to pre-check
  against, so an unauthorized edit just fails to save rather than
  being blocked client-side). Edits autosave 1.5s after typing pauses,
  through the same offline-queue-aware updateDocument path the
  explicit save already used. Guarded against firing a pointless save
  right after opening a document, and against clobbering newer
  keystrokes typed while a debounced save is still in flight.

Prerequisite: SessionStore now caches OutlineUserPreferences to
UserDefaults, loaded on launch before any network call — this needed
to stay valid on a cold offline launch, not just live in memory from
the last successful fetch. Still read-only while offline, unchanged.
This commit is contained in:
2026-08-19 15:29:38 +01:00
parent 0da26e0fed
commit 1229678c00
3 changed files with 145 additions and 18 deletions
@@ -51,6 +51,11 @@ struct DocumentReaderView: View {
) { ) {
self.apiClient = apiClient self.apiClient = apiClient
self.document = document self.document = document
// `separateEditingEnabled` can't be read from `@Environment` here
// environment values aren't populated yet inside a view's `init`,
// only from `body` onward. Defaults to `true` (today's only
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_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 self.onDeleted = onDeleted
@@ -69,7 +74,7 @@ struct DocumentReaderView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
if viewModel.isEditing { if viewModel.isEffectivelyEditable {
TextField("Title", text: $viewModel.title) TextField("Title", text: $viewModel.title)
.font(.largeTitle.weight(.bold)) .font(.largeTitle.weight(.bold))
.textFieldStyle(.plain) .textFieldStyle(.plain)
@@ -93,7 +98,7 @@ struct DocumentReaderView: View {
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId, documentId: viewModel.documentId,
isEditable: viewModel.isEditing isEditable: viewModel.isEffectivelyEditable
) )
if !viewModel.children.isEmpty { if !viewModel.children.isEmpty {
@@ -127,6 +132,7 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
} }
if viewModel.separateEditingEnabled {
Button { Button {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
} label: { } label: {
@@ -137,6 +143,13 @@ struct DocumentReaderView: View {
} }
} }
.disabled(viewModel.isSaving) .disabled(viewModel.isSaving)
} else if viewModel.isSaving {
// No Edit/Done affordance when documents are always
// editable this is the only feedback that an autosave
// is actually happening.
ProgressView().controlSize(.small)
.help("Saving…")
}
Button { Button {
isShowingNewDocumentSheet = true isShowingNewDocumentSheet = true
@@ -160,6 +173,17 @@ struct DocumentReaderView: View {
} }
} }
.task { await viewModel.loadFullContent() } .task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
.task { viewModel.separateEditingEnabled = session.userPreferences?.separateEditing ?? true }
.onChange(of: viewModel.text) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.onChange(of: viewModel.title) {
guard !viewModel.separateEditingEnabled else { return }
viewModel.scheduleAutosave()
}
.task { .task {
await viewModel.loadPinAndSubscriptionState() await viewModel.loadPinAndSubscriptionState()
} }
@@ -309,9 +333,11 @@ struct DocumentReaderView: View {
Divider() Divider()
if viewModel.separateEditingEnabled {
Button(viewModel.isEditing ? "Done Editing" : "Edit") { Button(viewModel.isEditing ? "Done Editing" : "Edit") {
Task { await viewModel.toggleEditing() } Task { await viewModel.toggleEditing() }
} }
}
// Membership management now lives in DocumentShareSheet's "People // Membership management now lives in DocumentShareSheet's "People
// with access" section, alongside the share link same sheet, // with access" section, alongside the share link same sheet,
// same isShowingShareSheet state. // same isShowingShareSheet state.
@@ -18,6 +18,38 @@ final class DocumentReaderViewModel {
var isSaving = false var isSaving = false
var saveErrorMessage: String? 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 /// Recent viewers, `views.list` filtered to entries that actually have a
/// `lastViewedAt` this is historical/aggregated view data, not live /// `lastViewedAt` this is historical/aggregated view data, not live
/// "viewing right now" presence (that needs the Hocuspocus collaboration /// "viewing right now" presence (that needs the Hocuspocus collaboration
@@ -38,7 +70,7 @@ final class DocumentReaderViewModel {
let documentId: String let documentId: String
private let apiClient: OutlineAPIClient private let apiClient: OutlineAPIClient
init(apiClient: OutlineAPIClient, document: OutlineDocument) { init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
self.apiClient = apiClient self.apiClient = apiClient
self.documentId = document.id self.documentId = document.id
self.title = document.title self.title = document.title
@@ -46,6 +78,9 @@ final class DocumentReaderViewModel {
self.text = document.text self.text = document.text
self.collectionId = document.collectionId self.collectionId = document.collectionId
self.isFullWidth = document.fullWidth ?? false 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, /// The list endpoint's copy of a document isn't guaranteed to be the full,
@@ -62,6 +97,8 @@ final class DocumentReaderViewModel {
text = full.text text = full.text
collectionId = full.collectionId collectionId = full.collectionId
isFullWidth = full.fullWidth ?? false isFullWidth = full.fullWidth ?? false
lastSyncedText = full.text
lastSyncedTitle = full.title
} 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."
} }
@@ -146,20 +183,55 @@ final class DocumentReaderViewModel {
} }
} }
/// Turning editing off saves; turning it on is just a mode switch. /// 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 { func toggleEditing() async {
guard isEditing else { guard isEditing else {
isEditing = true isEditing = true
return 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 isSaving = true
saveErrorMessage = nil saveErrorMessage = nil
defer { isSaving = false } defer { isSaving = false }
let sentTitle = title
let sentText = text
do { do {
let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: title, text: text)) let updated = try await apiClient.updateDocument(UpdateDocumentRequest(id: documentId, title: sentTitle, text: sentText))
title = updated.title // Only reconcile with the server's response if nothing changed
text = updated.text // locally while the request was in flight otherwise this
isEditing = false // 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 { } catch {
saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.") saveErrorMessage = outlineErrorMessage(error, fallback: "Couldn't save this document.")
} }
+29
View File
@@ -6,6 +6,13 @@ import OutlineKit
@Observable @Observable
final class SessionStore { final class SessionStore {
private static let serverURLDefaultsKey = "outline.serverURL" private static let serverURLDefaultsKey = "outline.serverURL"
/// Preferences now drive real editor behavior (separate editing, etc.),
/// not just a settings screen they need to survive a cold launch with
/// no network, not just live in memory from the last successful fetch.
/// Still read-only while offline (Settings already gates every toggle
/// on `isEffectivelyOnline`) this only makes the *last known* values
/// available, never lets them be changed without a server round-trip.
private static let userPreferencesDefaultsKey = "outline.userPreferences"
private let tokenStore: TokenStoring private let tokenStore: TokenStoring
private let defaults: UserDefaults private let defaults: UserDefaults
@@ -47,6 +54,7 @@ final class SessionStore {
if hasToken, let storedServerURL { if hasToken, let storedServerURL {
isSignedIn = true isSignedIn = true
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
userPreferences = Self.loadCachedPreferences(defaults: defaults)
} else { } else {
// Keychain and the sandboxed UserDefaults container don't // Keychain and the sandboxed UserDefaults container don't
// always survive together a Keychain item written by an // always survive together a Keychain item written by an
@@ -70,6 +78,24 @@ final class SessionStore {
isSignedIn = true isSignedIn = true
} }
/// `static` (not an instance method) so `init` can call it before every
/// stored property has a value same reason `makeAPIClient` is static.
private static func loadCachedPreferences(defaults: UserDefaults) -> OutlineUserPreferences? {
guard let data = defaults.data(forKey: userPreferencesDefaultsKey) else { return nil }
return try? JSONDecoder().decode(OutlineUserPreferences.self, from: data)
}
/// `nil` clears the cache instead of writing a `null` happens whenever
/// a fresh fetch legitimately comes back with no preferences set, so a
/// stale cached value from a previous account/state can't linger.
private func cachePreferences(_ preferences: OutlineUserPreferences?) {
guard let preferences, let data = try? JSONEncoder().encode(preferences) else {
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
return
}
defaults.set(data, forKey: Self.userPreferencesDefaultsKey)
}
private static func makeAPIClient( private static func makeAPIClient(
serverURL: URL, serverURL: URL,
tokenStore: TokenStoring, tokenStore: TokenStoring,
@@ -99,6 +125,7 @@ final class SessionStore {
teamAvatarURL = nil teamAvatarURL = nil
apiClient = nil apiClient = nil
cachingClient = nil cachingClient = nil
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
} }
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
@@ -119,6 +146,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language userLanguage = user.language
userPreferences = user.preferences userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings userNotificationSettings = user.notificationSettings
} }
@@ -132,6 +160,7 @@ final class SessionStore {
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
userLanguage = user.language userLanguage = user.language
userPreferences = user.preferences userPreferences = user.preferences
cachePreferences(user.preferences)
userNotificationSettings = user.notificationSettings userNotificationSettings = user.notificationSettings
teamName = team.name teamName = team.name
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL } teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }