Files
Outpost/Outpost/Features/Collections/DocumentPresentSheet.swift
T
Puranjay Savar Mattas ccbd84c05b feat: add Image Playground and fix Markdown image rendering
Image Playground integration: reader toolbar button ("Create Image
with Image Playground") opens the system generator, seeded with the
current text selection when there is one. Result uploads through the
same attachments.create/upload flow as everything else and inserts
Outline's own stable ![](/api/attachments.redirect?id=...) reference
at the caret, via a new generic TextInsertionRequest/
pendingTextInsertion mechanism on NativeTextViewWrapper (nothing
previously let an embedder insert text into the editor from outside
at all).

Along the way, found and fixed a real pre-existing gap: standard
![alt](url) Markdown images never rendered anywhere in the app -
services.images was never wired to anything but the no-op default, so
every such image silently fell back to dimmed raw source. Added
OutlineAPIClient.fetchAuthenticatedFile (Bearer-authed GET, for
attachments.redirect and similar) and OutlineImageProvider, a real
EmbeddedImageProvider backed by it, wired into every render surface
(reader, split-view preview, present sheet, collection overview).

Also fixes two Split View bugs surfaced while building this: the
raw-source pane was a plain SwiftUI TextEditor with no selection or
insertion hook, so the Image Playground button couldn't see a
highlighted selection there and generated images had nowhere to land;
replaced it with NativeTextViewWrapper in rawSourceMode (same engine,
no styling overhead, but now selection/insertion work like every
other pane). Also moved onSelectedTextChange's firing point earlier
in the delegate, since it was previously placed after the
rawSourceMode early-return and so could never fire for a raw-mode
editor at all. And images sized off a possibly-not-yet-settled text
container width during Split View's frequent per-keystroke rebuilds,
sticking at the wrong size until the document was reopened - now
re-measured once more a tick after layout settles.

Also reorganizes Settings: Command Palette and its full-workspace-
search toggle move out of Editor into a new Navigation section, since
they're about finding things, not about how documents are edited.
2026-08-20 14:49:34 +01:00

100 lines
3.6 KiB
Swift

#if os(macOS)
import SwiftUI
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
/// Distraction-free reading view — no toolbar/sidebar chrome, larger type.
/// Presentation is just a bigger render of the same markdown, not a real
/// slide-by-slide deck (Outline's own "Present" isn't slide-based either).
@MainActor
struct DocumentPresentSheet: View {
@Environment(\.dismiss) private var dismiss
let apiClient: OutlineAPIClient
let document: OutlineDocument
@State private var text: String
@State private var isLoading = false
@State private var errorMessage: String?
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
/// mechanism, needed here too since this sheet renders its own images.
@State private var imageReloadTick = 0
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
self.apiClient = apiClient
self.document = document
_text = State(initialValue: document.text)
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
ZStack(alignment: .topTrailing) {
if isLoading && text.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let errorMessage {
ContentUnavailableView {
Label("Couldn't Load Document", systemImage: "exclamationmark.triangle")
} description: {
Text(errorMessage)
}
} else {
ScrollView {
VStack(alignment: .leading) {
if let emoji = document.emoji {
Text(emoji).font(.system(size: 48))
}
Text(document.title.isEmpty ? "Untitled" : document.title)
.font(.system(size: 34, weight: .bold))
.padding(.bottom, 8)
NativeTextViewWrapper(
text: $text,
configuration: .init(
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
)
.font(.system(size: 18))
}
.frame(maxWidth: 720, alignment: .leading)
.padding(48)
.frame(maxWidth: .infinity)
}
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.title2)
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.padding(20)
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() }
}
private func load() async {
isLoading = true
defer { isLoading = false }
do {
text = try await apiClient.documentInfo(id: document.id).text
} catch {
errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.")
}
}
}
#endif