From bdde8642f76ff0ee0d467e9bf8e765fb44ddcc0e Mon Sep 17 00:00:00 2001 From: psmattas Date: Wed, 19 Aug 2026 15:42:35 +0100 Subject: [PATCH] feat(editor): Split View (raw Markdown / live preview), remove Sub-documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Outpost/Features/Account/SettingsView.swift | 25 +++ .../Collections/DocumentReaderView.swift | 161 ++++++++++++------ .../Collections/DocumentReaderViewModel.swift | 8 - Outpost/Root/AppNavigation.swift | 8 +- 4 files changed, 137 insertions(+), 65 deletions(-) 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