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  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
 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.
85 lines
3.4 KiB
Swift
85 lines
3.4 KiB
Swift
#if os(macOS)
|
|
import AppKit
|
|
import MarkdownEngine
|
|
import OutlineKit
|
|
|
|
/// Resolves `` 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
|