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:
@@ -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,45 +74,27 @@ 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() }
|
||||
}
|
||||
}
|
||||
Group {
|
||||
if canShowSplitView {
|
||||
splitViewContent
|
||||
} else {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
configuration: .init(heightBehavior: .fitsContent),
|
||||
documentId: viewModel.documentId,
|
||||
isEditable: viewModel.isEffectivelyEditable
|
||||
)
|
||||
|
||||
if !viewModel.children.isEmpty {
|
||||
childrenSection
|
||||
scrollingReaderContent
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: viewModel.isFullWidth ? .infinity : 900)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if viewModel.isLoading && !viewModel.text.isEmpty {
|
||||
ProgressView()
|
||||
@@ -296,29 +281,97 @@ struct DocumentReaderView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var childrenSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
/// 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)
|
||||
}
|
||||
|
||||
Text("Sub-documents")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(viewModel.children) { child in
|
||||
Button {
|
||||
onOpenChild(child)
|
||||
} label: {
|
||||
DocumentRowView(document: child)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.vertical, 4)
|
||||
/// 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])
|
||||
|
||||
if child.id != viewModel.children.last?.id {
|
||||
Divider()
|
||||
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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user