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.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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user