feat(editor): Split View (raw Markdown / live preview), remove Sub-documents

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.
This commit is contained in:
2026-08-19 15:42:35 +01:00
parent 1229678c00
commit bdde8642f7
4 changed files with 137 additions and 65 deletions
@@ -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 {
@@ -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") {
@@ -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 {
+5 -3
View File
@@ -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