From ccbd84c05bef756ff74c9de70942604736f46767 Mon Sep 17 00:00:00 2001 From: psmattas Date: Thu, 20 Aug 2026 14:49:34 +0100 Subject: [PATCH] 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. --- .../Caching/CachingOutlineAPIClient.swift | 4 + .../OutlineKit/Core/OutlineAPIClient.swift | 8 + .../OutlineKit/LiveOutlineAPIClient.swift | 39 ++++ .../CachingOutlineAPIClientTests.swift | 1 + Outpost/Features/Account/SettingsView.swift | 23 +++ .../CollectionOverviewContent.swift | 14 +- .../Collections/CollectionOverviewView.swift | 4 +- .../Collections/DocumentPresentSheet.swift | 14 +- .../Collections/DocumentReaderView.swift | 169 ++++++++++++++++-- Outpost/Root/AppNavigation.swift | 8 +- Outpost/Support/OutlineImageProvider.swift | 84 +++++++++ .../NativeTextViewCoordinator+Restyling.swift | 52 ++++++ ...tiveTextViewCoordinator+TextDelegate.swift | 8 + .../NativeTextViewCoordinator.swift | 2 + .../NativeTextViewSelectionTypes.swift | 27 +++ .../TextView/NativeTextViewWrapper.swift | 48 +++++ 16 files changed, 484 insertions(+), 21 deletions(-) create mode 100644 Outpost/Support/OutlineImageProvider.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index dc374c6..407faea 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -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) } diff --git a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift index 6ebf225..6f7ef5b 100644 --- a/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Core/OutlineAPIClient.swift @@ -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=`, 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. diff --git a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift index 5caaad1..4d9cd93 100644 --- a/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/LiveOutlineAPIClient.swift @@ -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)) } diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 3836b3c..2aa7cd3 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -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() } diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index e84f332..c4c27f0 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -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.") diff --git a/Outpost/Features/Collections/CollectionOverviewContent.swift b/Outpost/Features/Collections/CollectionOverviewContent.swift index 8c8e1f8..8ec8c7f 100644 --- a/Outpost/Features/Collections/CollectionOverviewContent.swift +++ b/Outpost/Features/Collections/CollectionOverviewContent.swift @@ -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 diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index 2436a06..f015d76 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -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, 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 } diff --git a/Outpost/Features/Collections/DocumentPresentSheet.swift b/Outpost/Features/Collections/DocumentPresentSheet.swift index 980b86d..e8777c1 100644 --- a/Outpost/Features/Collections/DocumentPresentSheet.swift +++ b/Outpost/Features/Collections/DocumentPresentSheet.swift @@ -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() } } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index 2de5c4e..b88d721 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -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,24 +502,31 @@ 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) - .padding(8) - .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) + 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) ScrollView { ZStack(alignment: .topLeading) { 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 diff --git a/Outpost/Root/AppNavigation.swift b/Outpost/Root/AppNavigation.swift index 94f0090..562a25c 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, 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 diff --git a/Outpost/Support/OutlineImageProvider.swift b/Outpost/Support/OutlineImageProvider.swift new file mode 100644 index 0000000..0d2a8c2 --- /dev/null +++ b/Outpost/Support/OutlineImageProvider.swift @@ -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=` — 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() + private let lock = NSLock() + private var inFlight: Set = [] + 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 diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 4b3c152..642a81b 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -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 diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index f5ed957..2656049 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -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 } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index 0306831..fb137b8 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -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 = [] var previousActiveTokenIndices: Set = [] var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift index 3489b22..26a1f95 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift @@ -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 + } +} diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index ebbd6ec..ba6f3b6 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -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, isWikiLinkActive: Binding = .constant(false), pendingInlineReplacement: Binding = .constant(nil), + pendingTextInsertion: Binding = .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