diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index f12443f..5c453c0 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -14,6 +14,7 @@ struct SettingsView: View { @Environment(SessionStore.self) private var session @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system + @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false @@ -92,6 +93,7 @@ struct SettingsView: View { private var sectionDetail: some View { switch section { case .appearance: appearanceDetail + case .editor: editorDetail case .profile: profileDetail case .preferences: preferencesDetail case .notifications: notificationsDetail @@ -139,6 +141,29 @@ struct SettingsView: View { } } + // MARK: - Editor + + /// Local-only, device-side settings for how this app's own editor + /// behaves — not synced to Outline (unlike Preferences, which mirrors + /// server-side settings the web app also reads/writes). Same category + /// `.general`/"Outpost" as Appearance, for the same reason. + private var editorDetail: some View { + VStack(alignment: .leading, spacing: 16) { + sectionHeader + Text("Settings for how documents are edited in this app.") + .font(.subheadline) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 6) { + Toggle("Split View", isOn: $isSplitViewEnabled) + Text("Edit raw Markdown on the left with a live-updating preview on the right, instead of a single editable view.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 480, alignment: .leading) + } + } + // MARK: - Profile private var profileDetail: some View { diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 80d3a90..a674997 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -13,6 +13,9 @@ struct DocumentReaderView: View { @Environment(SessionStore.self) private var session @Environment(StarStore.self) private var starStore @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false + /// Local-only Outpost setting (Settings → Editor), not synced to + /// Outline — see `SettingsView.editorDetail`. + @AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false @State private var viewModel: DocumentReaderViewModel let apiClient: OutlineAPIClient @@ -71,44 +74,26 @@ struct DocumentReaderView: View { session.networkMonitor.isOnline && !isOfflineModeEnabled } + /// Split View needs the full window height (each pane scrolls itself), + /// which an unbounded page-level `ScrollView` can't give it — a + /// `minHeight` inside one just resolves to exactly that minimum, not + /// "fill available space", since there's no bounded space to fill. + /// Only switches over once there's real content to show; loading/error + /// states still go through the normal scrolling layout. + private var canShowSplitView: Bool { + isSplitViewEnabled + && viewModel.isEffectivelyEditable + && viewModel.errorMessage == nil + && !(viewModel.isLoading && viewModel.text.isEmpty) + } + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - if viewModel.isEffectivelyEditable { - TextField("Title", text: $viewModel.title) - .font(.largeTitle.weight(.bold)) - .textFieldStyle(.plain) - } - - if viewModel.isLoading && viewModel.text.isEmpty { - ProgressView() - .frame(maxWidth: .infinity) - } else if let errorMessage = viewModel.errorMessage { - ContentUnavailableView { - Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") - } description: { - Text(errorMessage) - } actions: { - Button("Retry") { - Task { await viewModel.loadFullContent() } - } - } - } else { - NativeTextViewWrapper( - text: $viewModel.text, - configuration: .init(heightBehavior: .fitsContent), - documentId: viewModel.documentId, - isEditable: viewModel.isEffectivelyEditable - ) - - if !viewModel.children.isEmpty { - childrenSection - } - } + Group { + if canShowSplitView { + splitViewContent + } else { + scrollingReaderContent } - .padding() - .frame(maxWidth: viewModel.isFullWidth ? .infinity : 900) - .frame(maxWidth: .infinity) } .overlay(alignment: .topTrailing) { if viewModel.isLoading && !viewModel.text.isEmpty { @@ -296,31 +281,99 @@ struct DocumentReaderView: View { } } - private var childrenSection: some View { - VStack(alignment: .leading, spacing: 8) { - Divider() - .padding(.vertical, 4) - - Text("Sub-documents") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - - ForEach(viewModel.children) { child in - Button { - onOpenChild(child) - } label: { - DocumentRowView(document: child) + /// Today's single-pane layout — page-level `ScrollView` wrapping title + + /// content, used for the normal reading/editing view, and for every + /// loading/error state regardless of Split View. + private var scrollingReaderContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isEffectivelyEditable { + TextField("Title", text: $viewModel.title) + .font(.largeTitle.weight(.bold)) + .textFieldStyle(.plain) } - .buttonStyle(.plain) - .padding(.vertical, 4) - if child.id != viewModel.children.last?.id { - Divider() + if viewModel.isLoading && viewModel.text.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + } else if let errorMessage = viewModel.errorMessage { + ContentUnavailableView { + Label("Couldn't Load Document", systemImage: "exclamationmark.triangle") + } description: { + Text(errorMessage) + } actions: { + Button("Retry") { + Task { await viewModel.loadFullContent() } + } + } + } else { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init(heightBehavior: .fitsContent), + documentId: viewModel.documentId, + isEditable: viewModel.isEffectivelyEditable + ) } } + .padding() + .frame(maxWidth: viewModel.isFullWidth ? .infinity : 900) + .frame(maxWidth: .infinity) } } + /// Split View's layout — title fixed at the top (not part of either + /// scrolling pane), `splitEditorView` filling every remaining pixel of + /// the window below it. No outer `ScrollView` here on purpose: each + /// pane already scrolls itself, and nesting that inside another + /// unbounded scroll container is exactly what was capping both panes + /// at a fixed height instead of spanning the window. + private var splitViewContent: some View { + VStack(alignment: .leading, spacing: 12) { + TextField("Title", text: $viewModel.title) + .font(.largeTitle.weight(.bold)) + .textFieldStyle(.plain) + .padding([.horizontal, .top]) + + splitEditorView + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + /// Left is a plain, unrendered raw-text editor (deliberately not + /// `NativeTextViewWrapper` — just the literal Markdown source); right + /// is the same rich rendering used everywhere else in the app, + /// read-only, bound to the same `viewModel.text` so it updates live as + /// the left side is typed into. + /// + /// Scroll position between the two panes is **not** synchronized — the + /// only way to do that would be reaching into `NativeTextViewWrapper`'s + /// private internal view hierarchy to find its scroll view (the package + /// exposes no scroll position/delegate hook at all), which is fragile + /// enough to break silently on a package update. Flagged as a known + /// follow-up, not attempted here. + private var splitEditorView: some View { + HSplitView { + TextEditor(text: $viewModel.text) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(8) + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) + + ScrollView { + NativeTextViewWrapper( + text: $viewModel.text, + configuration: .init(heightBehavior: .fitsContent), + documentId: viewModel.documentId, + isEditable: false + ) + .padding(8) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + @ViewBuilder private var menuContent: some View { Button(starStore.isStarred(documentId: viewModel.documentId) ? "Unstar" : "Star") { diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 99afcde..7b2bc7b 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -10,7 +10,6 @@ final class DocumentReaderViewModel { var text: String var collectionId: String? var isFullWidth = false - var children: [OutlineDocument] = [] var isLoading = false var errorMessage: String? @@ -102,13 +101,6 @@ final class DocumentReaderViewModel { } catch { errorMessage = "Couldn't load this document. Check your connection and try again." } - - children = (try? await apiClient.listDocuments( - collectionId: nil, - parentDocumentId: documentId, - offset: 0, - limit: 100 - )) ?? [] } func loadViewers() async { diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index ce30d06..6335abf 100644 --- a/Outpost/Root/AppNavigation.swift +++ b/Outpost/Root/AppNavigation.swift @@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { /// explicitly built yet. Content lands section by section. enum SettingsSection: String, CaseIterable, Identifiable, Hashable { // General (ours) - case appearance, offlineSync, advanced, about + case appearance, editor, offlineSync, advanced, about // Account case profile, preferences, notifications, passkeys, apiAccess @@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { var category: SettingsCategory { switch self { - case .appearance, .offlineSync, .advanced, .about: + case .appearance, .editor, .offlineSync, .advanced, .about: return .general case .profile, .preferences, .notifications, .passkeys, .apiAccess: return .account @@ -57,6 +57,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { var title: String { switch self { case .appearance: return "Appearance" + case .editor: return "Editor" case .offlineSync: return "Offline & Sync" case .advanced: return "Advanced" case .about: return "About" @@ -85,6 +86,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { var icon: String { switch self { case .appearance: return "paintbrush" + case .editor: return "square.split.2x1" case .offlineSync: return "arrow.triangle.2.circlepath" case .advanced: return "wrench.and.screwdriver" case .about: return "info.circle" @@ -115,7 +117,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable { /// specified and built. var isImplemented: Bool { switch self { - case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: + case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess: return true default: return false