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.
This commit is contained in:
2026-08-20 14:49:34 +01:00
parent 69837b3471
commit ccbd84c05b
16 changed files with 484 additions and 21 deletions
@@ -343,6 +343,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
try await live.uploadAttachmentFile(result, fileData: fileData)
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
try await live.fetchAuthenticatedFile(path: path)
}
public func deleteAttachment(id: String) async throws {
try await live.deleteAttachment(id: id)
}
@@ -75,6 +75,14 @@ public protocol OutlineAPIClient: Sendable {
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
/// Fetches raw bytes from an authenticated, server-relative GET path
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
/// own editor embeds for uploaded images in document Markdown. Unlike
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
/// actual (often presigned, cross-host) storage URL; `path` is resolved
/// against the client's base URL, same as `uploadAttachmentFile`'s
/// `uploadUrl` handling.
func fetchAuthenticatedFile(path: String) async throws -> Data
/// Best-effort matches the shape every other simple `id`-only delete
/// in this API uses (`pins.delete`, `stars.delete`, ), not confirmed
/// against a live server specifically for attachments yet.
@@ -278,6 +278,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
}
}
public func fetchAuthenticatedFile(path: String) async throws -> Data {
guard let token = try? tokenStore.token() else {
throw OutlineAPIError.tokenUnavailable
}
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
throw OutlineAPIError.transport(URLError(.badURL))
}
var request = URLRequest(url: url)
// URLSession's default redirect handling drops Authorization on a
// cross-host redirect (same as a browser dropping cookies on one)
// exactly what's wanted here: authorize the request to Outline's own
// `attachments.redirect`, not the presigned storage URL it 302s to.
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await httpClient.send(request)
} catch let error as OutlineAPIError {
throw error
} catch {
throw OutlineAPIError.transport(error)
}
guard (200...299).contains(response.statusCode) else {
switch response.statusCode {
case 401:
throw OutlineAPIError.unauthorized
case 404:
throw OutlineAPIError.notFound
default:
throw OutlineAPIError.server(status: response.statusCode, message: nil)
}
}
return data
}
public func deleteAttachment(id: String) async throws {
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
}
@@ -91,6 +91,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
func deleteAttachment(id: String) async throws { throw NotStubbed() }
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
@@ -17,6 +17,7 @@ struct SettingsView: View {
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@@ -98,6 +99,7 @@ struct SettingsView: View {
switch section {
case .appearance: appearanceDetail
case .editor: editorDetail
case .navigation: navigationDetail
case .profile: profileDetail
case .preferences: preferencesDetail
case .notifications: notificationsDetail
@@ -188,6 +190,27 @@ struct SettingsView: View {
Divider().frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
Toggle("Image Playground", isOn: $isImagePlaygroundEnabled)
Text("Adds a \"Create Image with Image Playground\" button to the reader toolbar — generates an image from a text description (or your current selection, if any) and inserts it into the document. Requires macOS 15.1+ and a supported Mac — the toggle has no effect where it isn't available.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
}
}
// MARK: - Navigation
/// Local-only settings for finding your way around the app separate
/// from Editor, which is scoped to how documents are actually edited.
private var navigationDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Text("Settings for finding documents and collections.")
.font(.subheadline)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
@@ -10,9 +10,13 @@ import OutlineKit
/// `isSelectable` and link-opening both still need to work.
struct CollectionOverviewContent: View {
@State private var markdown: String
@State private var imageProvider: OutlineImageProvider
/// See `DocumentReaderView`'s `imageReloadTick`.
@State private var imageReloadTick = 0
init(collection: OutlineCollection) {
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
_markdown = State(initialValue: collection.description ?? "")
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
}
var body: some View {
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
NativeTextViewWrapper(
text: $markdown,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
}
}
#endif
@@ -3,6 +3,7 @@ import SwiftUI
import OutlineKit
struct CollectionOverviewView: View {
let apiClient: OutlineAPIClient
let collection: OutlineCollection
@State private var viewModel: DocumentsViewModel
@State private var selectedTab: CollectionTab = .overview
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
searchQuery: Binding<String>,
onOpenDocument: @escaping (OutlineDocument) -> Void
) {
self.apiClient = apiClient
self.collection = collection
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
if !trimmedSearchQuery.isEmpty {
searchResultsList
} else if selectedTab == .overview {
CollectionOverviewContent(collection: collection)
CollectionOverviewContent(apiClient: apiClient, collection: collection)
} else {
documentList
}
@@ -17,11 +17,17 @@ struct DocumentPresentSheet: View {
@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 {
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
NativeTextViewWrapper(
text: $text,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
heightBehavior: .fitsContent
),
isEditable: false
@@ -71,6 +77,12 @@ struct DocumentPresentSheet: View {
}
.frame(minWidth: 800, minHeight: 600)
.background(.background)
.animation(nil, value: imageReloadTick)
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.task { await load() }
}
@@ -5,6 +5,9 @@ import UniformTypeIdentifiers
import MarkdownEngine
import MarkdownEngineCodeBlocks
import OutlineKit
#if canImport(ImagePlayground)
import ImagePlayground
#endif
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
/// below must run on the main thread see the identical note on
@@ -19,6 +22,20 @@ struct DocumentReaderView: View {
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
/// eligibility) a supported OS with an unsupported Mac still reports
/// `false`, so this is the one check that matters, not just `#available`.
private var isImagePlaygroundSupported: Bool {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
return ImagePlaygroundViewController.isAvailable
}
#endif
return false
}
/// Outline's own "Show line numbers" preference (synced, read via
/// `session.userPreferences`, not `@AppStorage` this one's the
@@ -75,7 +92,27 @@ struct DocumentReaderView: View {
@State private var isShowingSearchSheet = false
@State private var isShowingShareSheet = false
@State private var isShowingNewDocumentSheet = false
@State private var isShowingImagePlayground = false
@State private var actionErrorMessage: String?
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
/// insert an Image Playground result's Markdown reference at the caret.
@State private var pendingTextInsertion: TextInsertionRequest?
/// Live-updated by `onSelectedTextChange` on the main editable pane;
/// `nil` when the selection is empty (caret only, nothing highlighted).
@State private var currentSelectedText: String?
/// Resolves `![alt](url)` images in this document shared by both
/// panes (main + split-view preview), since they render the same text.
@State private var imageProvider: OutlineImageProvider
/// Bumped by `imageProvider.onImageLoaded`. Not read for its value
/// just referenced via `.animation(nil, value:)` so SwiftUI re-evaluates
/// this view (and so `NativeTextViewWrapper.updateNSView` re-runs and
/// notices the provider's `fingerprint()` changed) once an async image
/// load completes. The engine has no polling of its own for this.
@State private var imageReloadTick = 0
/// Snapshot of `currentSelectedText` taken the moment the Image
/// Playground button is pressed the sheet's seed shouldn't shift if
/// the user's selection happens to change while it's open.
@State private var imagePlaygroundSeedText: String?
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange`
/// one array per instance (main pane, split-view preview pane), since
/// each lays the same text out at a different width and gets different
@@ -100,6 +137,7 @@ struct DocumentReaderView: View {
// behavior) and gets set for real in `.task` below once `session`
// is actually available.
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
self.onOpenChild = onOpenChild
self.onDeleted = onDeleted
self.onDocumentCreated = onDocumentCreated
@@ -157,6 +195,17 @@ struct DocumentReaderView: View {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
}
if isImagePlaygroundEnabled && isImagePlaygroundSupported {
Button {
imagePlaygroundSeedText = currentSelectedText
isShowingImagePlayground = true
} label: {
Image(systemName: "sparkles")
}
.help("Create Image with Image Playground")
.disabled(!viewModel.isEffectivelyEditable)
}
if viewModel.separateEditingEnabled {
Button {
Task { await viewModel.toggleEditing() }
@@ -197,6 +246,12 @@ struct DocumentReaderView: View {
.id(menuIdentity)
}
}
.task {
imageProvider.onImageLoaded = {
Task { @MainActor in imageReloadTick += 1 }
}
}
.animation(nil, value: imageReloadTick)
.task { await viewModel.loadFullContent() }
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
// for why this can't just be read at `init` time.
@@ -285,6 +340,41 @@ struct DocumentReaderView: View {
onOpenChild(child)
}
}
.modifier(ImagePlaygroundPresenter(
isPresented: $isShowingImagePlayground,
seedText: imagePlaygroundSeedText,
seedTitle: viewModel.title.isEmpty ? "Untitled" : viewModel.title,
onCompletion: { url in handleGeneratedImage(url) }
))
}
/// Uploads an Image Playground result the same way the reader would any
/// other attachment (`attachments.create` presigned target, then the
/// direct file POST see `OutlineAPIClient.uploadAttachmentFile`), then
/// inserts the hosted image's Markdown reference at the caret.
private func handleGeneratedImage(_ localURL: URL) {
Task {
do {
let data = try Data(contentsOf: localURL)
let created = try await apiClient.createAttachment(.init(
name: localURL.lastPathComponent,
contentType: "image/png",
size: data.count,
documentId: viewModel.documentId
))
try await apiClient.uploadAttachmentFile(created, fileData: data)
// Outline's own editor never embeds the raw (presigned/storage)
// upload URL in document Markdown it writes this stable
// redirect-by-id reference instead, which keeps resolving
// correctly even if the underlying storage URL rotates/expires.
pendingTextInsertion = TextInsertionRequest(
documentId: viewModel.documentId,
text: "\n\n![](/api/attachments.redirect?id=\(created.attachment.id))\n\n"
)
} catch {
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
}
}
}
/// Every toggle-backed piece of state shown as a checkmark inside
@@ -350,8 +440,9 @@ struct DocumentReaderView: View {
ZStack(alignment: .topLeading) {
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
textSubstitution: editorTextSubstitution,
textCompletion: editorTextCompletion,
@@ -360,7 +451,8 @@ struct DocumentReaderView: View {
),
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 }
)
if showCodeBlockLineNumbers {
ForEach(readerCodeBlocks) { selection in
@@ -394,11 +486,13 @@ struct DocumentReaderView: View {
}
}
/// 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.
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
/// hiding/styling, but still the real engine needed so selection
/// tracking and caret-position insertion, e.g. from the Image Playground
/// button, work here the same as everywhere else); 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
@@ -408,9 +502,15 @@ struct DocumentReaderView: View {
/// follow-up, not attempted here.
private var splitEditorView: some View {
HSplitView {
TextEditor(text: $viewModel.text)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
NativeTextViewWrapper(
text: $viewModel.text,
pendingTextInsertion: $pendingTextInsertion,
configuration: .init(rawSourceMode: true),
fontName: "SFMono-Regular",
documentId: viewModel.documentId,
isEditable: viewModel.isEffectivelyEditable,
onSelectedTextChange: { currentSelectedText = $0 }
)
.padding(8)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
@@ -419,13 +519,14 @@ struct DocumentReaderView: View {
NativeTextViewWrapper(
text: $viewModel.text,
configuration: .init(
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
codeBlock: editorCodeBlockStyle,
heightBehavior: .fitsContent
),
documentId: viewModel.documentId,
isEditable: false,
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
onSelectedTextChange: { currentSelectedText = $0 }
)
if showCodeBlockLineNumbers {
ForEach(previewCodeBlocks) { selection in
@@ -731,4 +832,44 @@ private struct CodeBlockLineNumberGutter: View {
.allowsHitTesting(false)
}
}
/// Applies `.imagePlaygroundSheet` only where it exists (macOS 15.1+, and
/// only once the `ImagePlayground` framework is actually linked in Xcode
/// see `SETUP.md`). A no-op modifier everywhere else, so this file stays
/// valid to build before that link-up happens.
private struct ImagePlaygroundPresenter: ViewModifier {
@Binding var isPresented: Bool
/// Highlighted document text at the moment the button was pressed, if
/// any seeds Image Playground's prompt instead of opening blank.
let seedText: String?
/// Document title, used as the concept's title when `seedText` is used.
let seedTitle: String
let onCompletion: (URL) -> Void
func body(content: Content) -> some View {
#if canImport(ImagePlayground)
if #available(macOS 15.1, *) {
let concepts: [ImagePlaygroundConcept] = {
guard let seedText, !seedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return []
}
return [ImagePlaygroundConcept.extracted(from: seedText, title: seedTitle)]
}()
content.imagePlaygroundSheet(
isPresented: $isPresented,
concepts: concepts,
onCompletion: { url in
isPresented = false
onCompletion(url)
},
onCancellation: { isPresented = false }
)
} else {
content
}
#else
content
#endif
}
}
#endif
+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, editor, offlineSync, advanced, about
case appearance, editor, navigation, 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, .editor, .offlineSync, .advanced, .about:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
return .general
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
return .account
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self {
case .appearance: return "Appearance"
case .editor: return "Editor"
case .navigation: return "Navigation"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
switch self {
case .appearance: return "paintbrush"
case .editor: return "square.split.2x1"
case .navigation: return "command"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
/// specified and built.
var isImplemented: Bool {
switch self {
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true
default:
return false
@@ -0,0 +1,84 @@
#if os(macOS)
import AppKit
import MarkdownEngine
import OutlineKit
/// Resolves `![alt](url)` Markdown image references for the editor.
///
/// `EmbeddedImageProvider.image(for:)` is called synchronously from the
/// styling pipeline and must return immediately it can't `await` a
/// network fetch inline. So a miss kicks off an async load in the
/// background, caches the result, and calls `onImageLoaded` (on the main
/// thread) once it lands; the embedder is responsible for turning that into
/// a real SwiftUI update (see `DocumentReaderView`'s `imageReloadTick`) so
/// `updateNSView` runs again, notices `fingerprint()` changed, and
/// restyles the engine has no polling of its own.
///
/// `url` is usually a server-relative path like
/// `/api/attachments.redirect?id=<uuid>` Outline's own stable reference
/// for an uploaded attachment, which needs the same Bearer auth as every
/// other OutlineKit request (`OutlineAPIClient.fetchAuthenticatedFile`).
/// A plain absolute `http(s)://` URL (an external image someone pasted) is
/// fetched directly instead, no auth attached.
///
/// Thread-safety mirrors `HighlighterSwiftBridge`: `NSCache` for the image
/// store (inherently thread-safe), a lock for the small bit of state that
/// isn't (`version`, `inFlight`) no actor isolation, since the engine may
/// call `image(for:)`/`fingerprint()` from whatever thread is styling.
final class OutlineImageProvider: EmbeddedImageProvider, @unchecked Sendable {
private let apiClient: OutlineAPIClient
private let cache = NSCache<NSString, NSImage>()
private let lock = NSLock()
private var inFlight: Set<String> = []
private var version = 0
/// Set by the embedder; called on the main thread whenever a load
/// completes and `fingerprint()` has changed.
var onImageLoaded: (@Sendable () -> Void)?
init(apiClient: OutlineAPIClient) {
self.apiClient = apiClient
}
func image(for reference: EmbeddedImageRequest) -> NSImage? {
let url = reference.name
if let cached = cache.object(forKey: url as NSString) {
return cached
}
beginLoad(url)
return nil
}
func fingerprint() -> AnyHashable {
lock.withLock { version }
}
private func beginLoad(_ url: String) {
let alreadyLoading: Bool = lock.withLock {
if inFlight.contains(url) { return true }
inFlight.insert(url)
return false
}
guard !alreadyLoading else { return }
Task {
defer { lock.withLock { inFlight.remove(url) } }
do {
let data: Data
if url.hasPrefix("http://") || url.hasPrefix("https://"), let externalURL = URL(string: url) {
(data, _) = try await URLSession.shared.data(from: externalURL)
} else {
data = try await apiClient.fetchAuthenticatedFile(path: url)
}
guard let image = NSImage(data: data) else { return }
cache.setObject(image, forKey: url as NSString)
lock.withLock { version += 1 }
onImageLoaded?()
} catch {
// Best-effort: a failed load just leaves the Markdown source
// visible (the engine's existing fallback for `image(for:)
// == nil`), no separate error UI for an inline image fetch.
}
}
}
}
#endif
@@ -200,6 +200,31 @@ extension NativeTextViewCoordinator {
nativeTextView?.updateWideTableOverlays()
}
}
// Standalone images (`![alt](url)`/`![[embed]]`) size themselves off
// the text container's width AT THIS MOMENT (see styleImageLinks/
// styleImageEmbeds' maxWidth) every keystroke rebuilds this whole
// pane when it's a read-only mirror of another editable view driving
// the same `text` binding (e.g. Split View's preview pane), and mid-
// typing the container can be reflowing (HSplitView divider, a
// `.fitsContent` pane still resizing) and read too small/zero for a
// moment. That undersized measurement then just sticks nothing
// else re-triggers a restyle once typing stops and the fingerprint-
// based image-load path (see NativeTextViewWrapper's `imageChanged`
// handling) doesn't fire for an already-cached image. Re-measure
// once more, one tick later, only when there's actually an image in
// the document same fix shape as the wide-table reconciliation
// above, just for image sizing instead of overlay frames.
let hasImages = (parsedForReplay?.classified.imageLink.isEmpty == false)
|| (parsedForReplay?.classified.imageEmbed.isEmpty == false)
if hasImages {
DispatchQueue.main.async { [weak self, weak textView] in
guard let self, let textView else { return }
let range = NSRange(location: 0, length: (textView.string as NSString).length)
guard range.length > 0 else { return }
self.restyleParagraphs([range], in: textView)
}
}
}
func restyleTextView(
@@ -417,6 +442,33 @@ extension NativeTextViewCoordinator {
classified: parsed.classified, blocks: parsed.blocks)
}
/// Inserts `request.text` at the current caret (replacing the selection,
/// if any) as a normal undoable edit. Simpler than
/// ``applyInlineReplacement(_:to:)`` no inline-token range, no
/// wiki-link ID side-channel, just a plain insert.
func applyTextInsertion(_ request: TextInsertionRequest, to textView: NSTextView) {
lastAppliedTextInsertionID = request.id
let range = textView.selectedRange()
textView.breakUndoCoalescing()
isProgrammaticEdit = true
defer { isProgrammaticEdit = false }
guard textView.shouldChangeText(in: range, replacementString: request.text) else {
return
}
textView.textStorage?.replaceCharacters(in: range, with: request.text)
textView.didChangeText()
textView.undoManager?.setActionName("Insert Image")
textView.breakUndoCoalescing()
let documentLength = (textView.string as NSString).length
let caretLocation = min(range.location + (request.text as NSString).length, documentLength)
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
}
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
lastAppliedInlineReplacementID = request.id
@@ -355,6 +355,14 @@ extension NativeTextViewCoordinator {
public func textViewDidChangeSelection(_ notification: Notification) {
guard let tv = notification.object as? NSTextView else { return }
// Cheap and mode-independent fire before the raw-mode/rebuild
// early-returns below, which would otherwise mean a `rawSourceMode`
// editor (e.g. Split View's raw-source pane) never reports a
// selection at all.
if !isRebuildingDocument {
let selRange = tv.selectedRange()
onSelectedTextChange?(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil)
}
// Raw mode: plain source no reveal, snap-back, or inline previews.
if configuration.rawSourceMode { return }
if isWritingToolsActive { return }
@@ -84,6 +84,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
var onSelectedTextChange: ((String?) -> Void)?
var didInitialFormatting: Bool = false
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
var didEnsureLayoutForCurrentDocument: Bool = false
@@ -106,6 +107,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
var wtUndoneDuringSession: Bool = false
var wtPostUndoSnapshot: String?
var lastAppliedInlineReplacementID: UUID?
var lastAppliedTextInsertionID: UUID?
var activeTokenIndices: Set<Int> = []
var previousActiveTokenIndices: Set<Int> = []
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
@@ -93,3 +93,30 @@ public struct InlineReplacementRequest: Sendable {
self.isImageEmbedMode = isImageEmbedMode
}
}
/// Request to insert literal text at the current caret (replacing the
/// current selection, if any) e.g. a Markdown image reference from an
/// embedder-side image picker or generator.
///
/// Embedders push one of these into
/// ``NativeTextViewWrapper/pendingTextInsertion`` to commit it. The engine
/// inserts it as a normal (undoable) edit, moves the caret past it, and
/// clears the binding. Unlike ``InlineReplacementRequest``, this doesn't
/// target an existing inline token it just inserts at wherever the caret
/// currently is.
public struct TextInsertionRequest: Sendable {
/// Stable identifier so the engine can detect already-applied requests
/// across SwiftUI re-renders.
public let id: UUID
/// Document the insertion targets. Ignored if it doesn't match the
/// editor's current `documentId` (prevents cross-document writes).
public let documentId: String
/// Storage-form text to insert at the caret.
public let text: String
public init(id: UUID = UUID(), documentId: String, text: String) {
self.id = id
self.documentId = documentId
self.text = text
}
}
@@ -55,6 +55,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// Push a replacement into the editor by setting this to a non-nil value;
/// the engine applies it on the next update and then clears the binding.
@Binding public var pendingInlineReplacement: InlineReplacementRequest?
/// Push a plain-text insertion at the caret by setting this to a non-nil
/// value; the engine applies it on the next update and then clears the
/// binding. See ``TextInsertionRequest``.
@Binding public var pendingTextInsertion: TextInsertionRequest?
/// The full editor configuration (theme + services + style toggles). Engine
/// embedders construct this themselves and pass it in; the wrapper does
/// not read UserDefaults or know about app-specific colors/services.
@@ -95,6 +99,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
/// Fires when the set of visible code blocks changes, so embedders can
/// overlay copy buttons (see ``CodeBlockButton``).
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
/// Fires whenever the text selection changes. `nil` for an empty (caret-only)
/// selection, otherwise the plain-text substring currently selected useful
/// for embedder features that act on "whatever's selected" (e.g. seeding a
/// generator's prompt).
public var onSelectedTextChange: ((String?) -> Void)?
/// Fires after the user toggles any of the three spell/grammar/auto-correction
/// menu items. Embedders persist the policy and pass it back via
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
@@ -141,6 +150,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
text: Binding<String>,
isWikiLinkActive: Binding<Bool> = .constant(false),
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
configuration: MarkdownEditorConfiguration = .default,
fontName: String = "SF Pro",
fontSize: CGFloat = 16,
@@ -153,6 +163,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
onSelectedTextChange: ((String?) -> Void)? = nil,
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
placeholder: NSAttributedString? = nil,
header: AnyView? = nil,
@@ -166,6 +177,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self._text = text
self._isWikiLinkActive = isWikiLinkActive
self._pendingInlineReplacement = pendingInlineReplacement
self._pendingTextInsertion = pendingTextInsertion
self.configuration = configuration
self.fontName = fontName
self.fontSize = fontSize
@@ -178,6 +190,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
self.onInlineSelectionChange = onInlineSelectionChange
self.onInlinePreviewKey = onInlinePreviewKey
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
self.onSelectedTextChange = onSelectedTextChange
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
self.placeholder = placeholder
self.header = header
@@ -328,6 +341,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
context.coordinator.onSelectedTextChange = onSelectedTextChange
textView.recalcOverscroll(for: scrollView)
textView.setPlaceholder(placeholder)
@@ -541,6 +555,26 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
if fullRange.length > 0 {
context.coordinator.restyleParagraphs([fullRange], in: textView)
}
// An image's display width is measured off the text container's
// CURRENT width (see styleImageLinks/styleImageEmbeds' maxWidth),
// which may not have settled to its real value yet in this same
// pass e.g. right after a text change that also grows/shrinks
// the pane (Split View's HSplitView reflow, `.fitsContent`
// resizing). A too-small width here falls back to a small
// default and, with nothing else in this document changing
// afterward, stays wrong until something else forces a restyle
// (reopening the document). Re-measure one runloop tick later,
// once layout has actually settled mirrors the WideTableOverlay
// reconciliation below.
if imageChanged {
let coordinator = context.coordinator
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
let range = NSRange(location: 0, length: (textView.string as NSString).length)
guard range.length > 0 else { return }
coordinator.restyleParagraphs([range], in: textView)
}
}
}
textView.isEditable = isEditable
textView.isSelectable = true
@@ -562,6 +596,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
}
return
}
if let pendingTextInsertion {
if pendingTextInsertion.documentId == documentId,
context.coordinator.lastAppliedTextInsertionID != pendingTextInsertion.id {
context.coordinator.applyTextInsertion(pendingTextInsertion, to: textView)
}
DispatchQueue.main.async {
if self.pendingTextInsertion?.id == pendingTextInsertion.id {
self.pendingTextInsertion = nil
}
}
return
}
if context.coordinator.didInitialFormatting
&& context.coordinator.lastSyncedText == text
&& !fontChanged {
@@ -669,6 +715,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
context.coordinator.onSelectedTextChange = onSelectedTextChange
context.coordinator.didInitialFormatting = true
}
@@ -691,6 +738,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
coordinator.onSelectedTextChange = onSelectedTextChange
coordinator.onInlinePreviewKey = onInlinePreviewKey
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking