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  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.
This commit is contained in:
@@ -343,6 +343,10 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
try await live.uploadAttachmentFile(result, fileData: fileData)
|
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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await live.deleteAttachment(id: id)
|
try await live.deleteAttachment(id: id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,14 @@ public protocol OutlineAPIClient: Sendable {
|
|||||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
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
|
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||||
/// against a live server specifically for attachments yet.
|
/// 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 {
|
public func deleteAttachment(id: String) async throws {
|
||||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
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 currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { 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 deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||||
func updateUserName(_ request: UpdateUserNameRequest) 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.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||||
|
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||||
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@@ -98,6 +99,7 @@ struct SettingsView: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .appearance: appearanceDetail
|
case .appearance: appearanceDetail
|
||||||
case .editor: editorDetail
|
case .editor: editorDetail
|
||||||
|
case .navigation: navigationDetail
|
||||||
case .profile: profileDetail
|
case .profile: profileDetail
|
||||||
case .preferences: preferencesDetail
|
case .preferences: preferencesDetail
|
||||||
case .notifications: notificationsDetail
|
case .notifications: notificationsDetail
|
||||||
@@ -188,6 +190,27 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider().frame(maxWidth: 480)
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
|
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.")
|
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.
|
/// `isSelectable` and link-opening both still need to work.
|
||||||
struct CollectionOverviewContent: View {
|
struct CollectionOverviewContent: View {
|
||||||
@State private var markdown: String
|
@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 ?? "")
|
_markdown = State(initialValue: collection.description ?? "")
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $markdown,
|
text: $markdown,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
|
|||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
struct CollectionOverviewView: View {
|
struct CollectionOverviewView: View {
|
||||||
|
let apiClient: OutlineAPIClient
|
||||||
let collection: OutlineCollection
|
let collection: OutlineCollection
|
||||||
@State private var viewModel: DocumentsViewModel
|
@State private var viewModel: DocumentsViewModel
|
||||||
@State private var selectedTab: CollectionTab = .overview
|
@State private var selectedTab: CollectionTab = .overview
|
||||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
|||||||
searchQuery: Binding<String>,
|
searchQuery: Binding<String>,
|
||||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||||
) {
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
self.collection = collection
|
self.collection = collection
|
||||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||||
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
|
|||||||
if !trimmedSearchQuery.isEmpty {
|
if !trimmedSearchQuery.isEmpty {
|
||||||
searchResultsList
|
searchResultsList
|
||||||
} else if selectedTab == .overview {
|
} else if selectedTab == .overview {
|
||||||
CollectionOverviewContent(collection: collection)
|
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||||
} else {
|
} else {
|
||||||
documentList
|
documentList
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,17 @@ struct DocumentPresentSheet: View {
|
|||||||
@State private var text: String
|
@State private var text: String
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var errorMessage: String?
|
@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) {
|
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||||
self.apiClient = apiClient
|
self.apiClient = apiClient
|
||||||
self.document = document
|
self.document = document
|
||||||
_text = State(initialValue: document.text)
|
_text = State(initialValue: document.text)
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $text,
|
text: $text,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
isEditable: false
|
isEditable: false
|
||||||
@@ -71,6 +77,12 @@ struct DocumentPresentSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(minWidth: 800, minHeight: 600)
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
.background(.background)
|
.background(.background)
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import UniformTypeIdentifiers
|
|||||||
import MarkdownEngine
|
import MarkdownEngine
|
||||||
import MarkdownEngineCodeBlocks
|
import MarkdownEngineCodeBlocks
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
#if canImport(ImagePlayground)
|
||||||
|
import ImagePlayground
|
||||||
|
#endif
|
||||||
|
|
||||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||||
/// below must run on the main thread — see the identical note on
|
/// 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.splitViewEnabled") private var isSplitViewEnabled = false
|
||||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = 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
|
/// Outline's own "Show line numbers" preference (synced, read via
|
||||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||||
@@ -75,7 +92,27 @@ struct DocumentReaderView: View {
|
|||||||
@State private var isShowingSearchSheet = false
|
@State private var isShowingSearchSheet = false
|
||||||
@State private var isShowingShareSheet = false
|
@State private var isShowingShareSheet = false
|
||||||
@State private var isShowingNewDocumentSheet = false
|
@State private var isShowingNewDocumentSheet = false
|
||||||
|
@State private var isShowingImagePlayground = false
|
||||||
@State private var actionErrorMessage: String?
|
@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 `` 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` —
|
/// Populated live by `NativeTextViewWrapper`'s `onCodeBlockSelectionChange` —
|
||||||
/// one array per instance (main pane, split-view preview pane), since
|
/// one array per instance (main pane, split-view preview pane), since
|
||||||
/// each lays the same text out at a different width and gets different
|
/// 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`
|
// behavior) and gets set for real in `.task` below once `session`
|
||||||
// is actually available.
|
// is actually available.
|
||||||
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
_viewModel = State(initialValue: DocumentReaderViewModel(apiClient: apiClient, document: document))
|
||||||
|
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||||
self.onOpenChild = onOpenChild
|
self.onOpenChild = onOpenChild
|
||||||
self.onDeleted = onDeleted
|
self.onDeleted = onDeleted
|
||||||
self.onDocumentCreated = onDocumentCreated
|
self.onDocumentCreated = onDocumentCreated
|
||||||
@@ -157,6 +195,17 @@ struct DocumentReaderView: View {
|
|||||||
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
|
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 {
|
if viewModel.separateEditingEnabled {
|
||||||
Button {
|
Button {
|
||||||
Task { await viewModel.toggleEditing() }
|
Task { await viewModel.toggleEditing() }
|
||||||
@@ -197,6 +246,12 @@ struct DocumentReaderView: View {
|
|||||||
.id(menuIdentity)
|
.id(menuIdentity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
imageProvider.onImageLoaded = {
|
||||||
|
Task { @MainActor in imageReloadTick += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(nil, value: imageReloadTick)
|
||||||
.task { await viewModel.loadFullContent() }
|
.task { await viewModel.loadFullContent() }
|
||||||
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
// See the doc comment on `DocumentReaderViewModel.separateEditingEnabled`
|
||||||
// for why this can't just be read at `init` time.
|
// for why this can't just be read at `init` time.
|
||||||
@@ -285,6 +340,41 @@ struct DocumentReaderView: View {
|
|||||||
onOpenChild(child)
|
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)\n\n"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
actionErrorMessage = outlineErrorMessage(error, fallback: "Couldn't add the generated image.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every toggle-backed piece of state shown as a checkmark inside
|
/// Every toggle-backed piece of state shown as a checkmark inside
|
||||||
@@ -350,8 +440,9 @@ struct DocumentReaderView: View {
|
|||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
|
pendingTextInsertion: $pendingTextInsertion,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
codeBlock: editorCodeBlockStyle,
|
codeBlock: editorCodeBlockStyle,
|
||||||
textSubstitution: editorTextSubstitution,
|
textSubstitution: editorTextSubstitution,
|
||||||
textCompletion: editorTextCompletion,
|
textCompletion: editorTextCompletion,
|
||||||
@@ -360,7 +451,8 @@ struct DocumentReaderView: View {
|
|||||||
),
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: viewModel.isEffectivelyEditable,
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
onCodeBlockSelectionChange: { readerCodeBlocks = $0 }
|
onCodeBlockSelectionChange: { readerCodeBlocks = $0 },
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 }
|
||||||
)
|
)
|
||||||
if showCodeBlockLineNumbers {
|
if showCodeBlockLineNumbers {
|
||||||
ForEach(readerCodeBlocks) { selection in
|
ForEach(readerCodeBlocks) { selection in
|
||||||
@@ -394,11 +486,13 @@ struct DocumentReaderView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Left is a plain, unrendered raw-text editor (deliberately not
|
/// Left is the literal Markdown source in `rawSourceMode` (no syntax
|
||||||
/// `NativeTextViewWrapper` — just the literal Markdown source); right
|
/// hiding/styling, but still the real engine — needed so selection
|
||||||
/// is the same rich rendering used everywhere else in the app,
|
/// tracking and caret-position insertion, e.g. from the Image Playground
|
||||||
/// read-only, bound to the same `viewModel.text` so it updates live as
|
/// button, work here the same as everywhere else); right is the same
|
||||||
/// the left side is typed into.
|
/// 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
|
/// Scroll position between the two panes is **not** synchronized — the
|
||||||
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
/// only way to do that would be reaching into `NativeTextViewWrapper`'s
|
||||||
@@ -408,24 +502,31 @@ struct DocumentReaderView: View {
|
|||||||
/// follow-up, not attempted here.
|
/// follow-up, not attempted here.
|
||||||
private var splitEditorView: some View {
|
private var splitEditorView: some View {
|
||||||
HSplitView {
|
HSplitView {
|
||||||
TextEditor(text: $viewModel.text)
|
NativeTextViewWrapper(
|
||||||
.font(.system(.body, design: .monospaced))
|
text: $viewModel.text,
|
||||||
.scrollContentBackground(.hidden)
|
pendingTextInsertion: $pendingTextInsertion,
|
||||||
.padding(8)
|
configuration: .init(rawSourceMode: true),
|
||||||
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
fontName: "SFMono-Regular",
|
||||||
|
documentId: viewModel.documentId,
|
||||||
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 }
|
||||||
|
)
|
||||||
|
.padding(8)
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
codeBlock: editorCodeBlockStyle,
|
codeBlock: editorCodeBlockStyle,
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent
|
||||||
),
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
onCodeBlockSelectionChange: { previewCodeBlocks = $0 }
|
onCodeBlockSelectionChange: { previewCodeBlocks = $0 },
|
||||||
|
onSelectedTextChange: { currentSelectedText = $0 }
|
||||||
)
|
)
|
||||||
if showCodeBlockLineNumbers {
|
if showCodeBlockLineNumbers {
|
||||||
ForEach(previewCodeBlocks) { selection in
|
ForEach(previewCodeBlocks) { selection in
|
||||||
@@ -731,4 +832,44 @@ private struct CodeBlockLineNumberGutter: View {
|
|||||||
.allowsHitTesting(false)
|
.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
|
#endif
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
|||||||
/// explicitly built yet. Content lands section by section.
|
/// explicitly built yet. Content lands section by section.
|
||||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||||
// General (ours)
|
// General (ours)
|
||||||
case appearance, editor, offlineSync, advanced, about
|
case appearance, editor, navigation, offlineSync, advanced, about
|
||||||
|
|
||||||
// Account
|
// Account
|
||||||
case profile, preferences, notifications, passkeys, apiAccess
|
case profile, preferences, notifications, passkeys, apiAccess
|
||||||
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
|
|
||||||
var category: SettingsCategory {
|
var category: SettingsCategory {
|
||||||
switch self {
|
switch self {
|
||||||
case .appearance, .editor, .offlineSync, .advanced, .about:
|
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||||
return .general
|
return .general
|
||||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||||
return .account
|
return .account
|
||||||
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "Appearance"
|
case .appearance: return "Appearance"
|
||||||
case .editor: return "Editor"
|
case .editor: return "Editor"
|
||||||
|
case .navigation: return "Navigation"
|
||||||
case .offlineSync: return "Offline & Sync"
|
case .offlineSync: return "Offline & Sync"
|
||||||
case .advanced: return "Advanced"
|
case .advanced: return "Advanced"
|
||||||
case .about: return "About"
|
case .about: return "About"
|
||||||
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .appearance: return "paintbrush"
|
case .appearance: return "paintbrush"
|
||||||
case .editor: return "square.split.2x1"
|
case .editor: return "square.split.2x1"
|
||||||
|
case .navigation: return "command"
|
||||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||||
case .advanced: return "wrench.and.screwdriver"
|
case .advanced: return "wrench.and.screwdriver"
|
||||||
case .about: return "info.circle"
|
case .about: return "info.circle"
|
||||||
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
|||||||
/// specified and built.
|
/// specified and built.
|
||||||
var isImplemented: Bool {
|
var isImplemented: Bool {
|
||||||
switch self {
|
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
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#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
|
||||||
+52
@@ -200,6 +200,31 @@ extension NativeTextViewCoordinator {
|
|||||||
nativeTextView?.updateWideTableOverlays()
|
nativeTextView?.updateWideTableOverlays()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Standalone images (``/`![[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(
|
func restyleTextView(
|
||||||
@@ -417,6 +442,33 @@ extension NativeTextViewCoordinator {
|
|||||||
classified: parsed.classified, blocks: parsed.blocks)
|
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) {
|
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
|
||||||
lastAppliedInlineReplacementID = request.id
|
lastAppliedInlineReplacementID = request.id
|
||||||
|
|
||||||
|
|||||||
+8
@@ -355,6 +355,14 @@ extension NativeTextViewCoordinator {
|
|||||||
|
|
||||||
public func textViewDidChangeSelection(_ notification: Notification) {
|
public func textViewDidChangeSelection(_ notification: Notification) {
|
||||||
guard let tv = notification.object as? NSTextView else { return }
|
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.
|
// Raw mode: plain source — no reveal, snap-back, or inline previews.
|
||||||
if configuration.rawSourceMode { return }
|
if configuration.rawSourceMode { return }
|
||||||
if isWritingToolsActive { return }
|
if isWritingToolsActive { return }
|
||||||
|
|||||||
+2
@@ -84,6 +84,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
|
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
|
||||||
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
||||||
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||||
|
var onSelectedTextChange: ((String?) -> Void)?
|
||||||
var didInitialFormatting: Bool = false
|
var didInitialFormatting: Bool = false
|
||||||
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
||||||
var didEnsureLayoutForCurrentDocument: Bool = false
|
var didEnsureLayoutForCurrentDocument: Bool = false
|
||||||
@@ -106,6 +107,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var wtUndoneDuringSession: Bool = false
|
var wtUndoneDuringSession: Bool = false
|
||||||
var wtPostUndoSnapshot: String?
|
var wtPostUndoSnapshot: String?
|
||||||
var lastAppliedInlineReplacementID: UUID?
|
var lastAppliedInlineReplacementID: UUID?
|
||||||
|
var lastAppliedTextInsertionID: UUID?
|
||||||
var activeTokenIndices: Set<Int> = []
|
var activeTokenIndices: Set<Int> = []
|
||||||
var previousActiveTokenIndices: Set<Int> = []
|
var previousActiveTokenIndices: Set<Int> = []
|
||||||
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
||||||
|
|||||||
Vendored
+27
@@ -93,3 +93,30 @@ public struct InlineReplacementRequest: Sendable {
|
|||||||
self.isImageEmbedMode = isImageEmbedMode
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+48
@@ -55,6 +55,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
/// Push a replacement into the editor by setting this to a non-nil value;
|
/// 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.
|
/// the engine applies it on the next update and then clears the binding.
|
||||||
@Binding public var pendingInlineReplacement: InlineReplacementRequest?
|
@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
|
/// The full editor configuration (theme + services + style toggles). Engine
|
||||||
/// embedders construct this themselves and pass it in; the wrapper does
|
/// embedders construct this themselves and pass it in; the wrapper does
|
||||||
/// not read UserDefaults or know about app-specific colors/services.
|
/// 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
|
/// Fires when the set of visible code blocks changes, so embedders can
|
||||||
/// overlay copy buttons (see ``CodeBlockButton``).
|
/// overlay copy buttons (see ``CodeBlockButton``).
|
||||||
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
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
|
/// Fires after the user toggles any of the three spell/grammar/auto-correction
|
||||||
/// menu items. Embedders persist the policy and pass it back via
|
/// menu items. Embedders persist the policy and pass it back via
|
||||||
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
||||||
@@ -141,6 +150,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
text: Binding<String>,
|
text: Binding<String>,
|
||||||
isWikiLinkActive: Binding<Bool> = .constant(false),
|
isWikiLinkActive: Binding<Bool> = .constant(false),
|
||||||
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
||||||
|
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
|
||||||
configuration: MarkdownEditorConfiguration = .default,
|
configuration: MarkdownEditorConfiguration = .default,
|
||||||
fontName: String = "SF Pro",
|
fontName: String = "SF Pro",
|
||||||
fontSize: CGFloat = 16,
|
fontSize: CGFloat = 16,
|
||||||
@@ -153,6 +163,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
|
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
|
||||||
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
||||||
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
||||||
|
onSelectedTextChange: ((String?) -> Void)? = nil,
|
||||||
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
||||||
placeholder: NSAttributedString? = nil,
|
placeholder: NSAttributedString? = nil,
|
||||||
header: AnyView? = nil,
|
header: AnyView? = nil,
|
||||||
@@ -166,6 +177,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
self._text = text
|
self._text = text
|
||||||
self._isWikiLinkActive = isWikiLinkActive
|
self._isWikiLinkActive = isWikiLinkActive
|
||||||
self._pendingInlineReplacement = pendingInlineReplacement
|
self._pendingInlineReplacement = pendingInlineReplacement
|
||||||
|
self._pendingTextInsertion = pendingTextInsertion
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.fontName = fontName
|
self.fontName = fontName
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
@@ -178,6 +190,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
self.onInlineSelectionChange = onInlineSelectionChange
|
self.onInlineSelectionChange = onInlineSelectionChange
|
||||||
self.onInlinePreviewKey = onInlinePreviewKey
|
self.onInlinePreviewKey = onInlinePreviewKey
|
||||||
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
self.onSelectedTextChange = onSelectedTextChange
|
||||||
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
||||||
self.placeholder = placeholder
|
self.placeholder = placeholder
|
||||||
self.header = header
|
self.header = header
|
||||||
@@ -328,6 +341,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
|
|
||||||
textView.recalcOverscroll(for: scrollView)
|
textView.recalcOverscroll(for: scrollView)
|
||||||
textView.setPlaceholder(placeholder)
|
textView.setPlaceholder(placeholder)
|
||||||
@@ -541,6 +555,26 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
if fullRange.length > 0 {
|
if fullRange.length > 0 {
|
||||||
context.coordinator.restyleParagraphs([fullRange], in: textView)
|
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.isEditable = isEditable
|
||||||
textView.isSelectable = true
|
textView.isSelectable = true
|
||||||
@@ -562,6 +596,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
return
|
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
|
if context.coordinator.didInitialFormatting
|
||||||
&& context.coordinator.lastSyncedText == text
|
&& context.coordinator.lastSyncedText == text
|
||||||
&& !fontChanged {
|
&& !fontChanged {
|
||||||
@@ -669,6 +715,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
context.coordinator.didInitialFormatting = true
|
context.coordinator.didInitialFormatting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +738,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
|
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
|
||||||
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
||||||
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||||
|
coordinator.onSelectedTextChange = onSelectedTextChange
|
||||||
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||||
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
||||||
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
||||||
|
|||||||
Reference in New Issue
Block a user