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:
@@ -51,6 +51,11 @@ struct DocumentReaderView: View {
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
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))
|
||||
self.onOpenChild = onOpenChild
|
||||
self.onDeleted = onDeleted
|
||||
@@ -69,7 +74,7 @@ struct DocumentReaderView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if viewModel.isEditing {
|
||||
if viewModel.isEffectivelyEditable {
|
||||
TextField("Title", text: $viewModel.title)
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.textFieldStyle(.plain)
|
||||
@@ -93,7 +98,7 @@ struct DocumentReaderView: View {
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEditing
|
||||
isEditable: viewModel.isEffectivelyEditable
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
@@ -127,6 +132,7 @@ struct DocumentReaderView: View {
|
||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
||||
}
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
} label: {
|
||||
@@ -137,6 +143,13 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
isShowingNewDocumentSheet = true
|
||||
@@ -160,6 +173,17 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
await viewModel.loadPinAndSubscriptionState()
|
||||
}
|
||||
@@ -309,9 +333,11 @@ struct DocumentReaderView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
if viewModel.separateEditingEnabled {
|
||||
Button(viewModel.isEditing ? "Done Editing" : "Edit") {
|
||||
Task { await viewModel.toggleEditing() }
|
||||
}
|
||||
}
|
||||
// Membership management now lives in DocumentShareSheet's "People
|
||||
// with access" section, alongside the share link — same sheet,
|
||||
// same isShowingShareSheet state.
|
||||
|
||||
@@ -18,6 +18,38 @@ final class DocumentReaderViewModel {
|
||||
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
|
||||
@@ -38,7 +70,7 @@ final class DocumentReaderViewModel {
|
||||
let documentId: String
|
||||
private let apiClient: OutlineAPIClient
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument, separateEditingEnabled: Bool = true) {
|
||||
self.apiClient = apiClient
|
||||
self.documentId = document.id
|
||||
self.title = document.title
|
||||
@@ -46,6 +78,9 @@ final class DocumentReaderViewModel {
|
||||
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,
|
||||
@@ -62,6 +97,8 @@ final class DocumentReaderViewModel {
|
||||
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."
|
||||
}
|
||||
@@ -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 {
|
||||
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: title, text: text))
|
||||
title = updated.title
|
||||
text = updated.text
|
||||
isEditing = false
|
||||
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.")
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import OutlineKit
|
||||
@Observable
|
||||
final class SessionStore {
|
||||
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 defaults: UserDefaults
|
||||
@@ -47,6 +54,7 @@ final class SessionStore {
|
||||
if hasToken, let storedServerURL {
|
||||
isSignedIn = true
|
||||
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
||||
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||
} else {
|
||||
// Keychain and the sandboxed UserDefaults container don't
|
||||
// always survive together — a Keychain item written by an
|
||||
@@ -70,6 +78,24 @@ final class SessionStore {
|
||||
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(
|
||||
serverURL: URL,
|
||||
tokenStore: TokenStoring,
|
||||
@@ -99,6 +125,7 @@ final class SessionStore {
|
||||
teamAvatarURL = nil
|
||||
apiClient = nil
|
||||
cachingClient = nil
|
||||
defaults.removeObject(forKey: Self.userPreferencesDefaultsKey)
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
}
|
||||
|
||||
@@ -132,6 +160,7 @@ final class SessionStore {
|
||||
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
userLanguage = user.language
|
||||
userPreferences = user.preferences
|
||||
cachePreferences(user.preferences)
|
||||
userNotificationSettings = user.notificationSettings
|
||||
teamName = team.name
|
||||
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
||||
|
||||
Reference in New Issue
Block a user