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)
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
try await live.fetchAuthenticatedFile(path: path)
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await live.deleteAttachment(id: id)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,14 @@ public protocol OutlineAPIClient: Sendable {
|
||||
/// target. See `OutlineAttachment`/`CreateAttachmentResult`.
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws
|
||||
/// Fetches raw bytes from an authenticated, server-relative GET path —
|
||||
/// e.g. `/api/attachments.redirect?id=<uuid>`, the reference Outline's
|
||||
/// own editor embeds for uploaded images in document Markdown. Unlike
|
||||
/// `post`'s RPC endpoints, this is a GET that 302-redirects to the
|
||||
/// actual (often presigned, cross-host) storage URL; `path` is resolved
|
||||
/// against the client's base URL, same as `uploadAttachmentFile`'s
|
||||
/// `uploadUrl` handling.
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data
|
||||
/// Best-effort — matches the shape every other simple `id`-only delete
|
||||
/// in this API uses (`pins.delete`, `stars.delete`, …), not confirmed
|
||||
/// against a live server specifically for attachments yet.
|
||||
|
||||
@@ -278,6 +278,45 @@ public actor LiveOutlineAPIClient: OutlineAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
public func fetchAuthenticatedFile(path: String) async throws -> Data {
|
||||
guard let token = try? tokenStore.token() else {
|
||||
throw OutlineAPIError.tokenUnavailable
|
||||
}
|
||||
// Same host-relative-or-absolute resolution as uploadAttachmentFile's uploadUrl.
|
||||
guard let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
|
||||
throw OutlineAPIError.transport(URLError(.badURL))
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
// URLSession's default redirect handling drops Authorization on a
|
||||
// cross-host redirect (same as a browser dropping cookies on one) —
|
||||
// exactly what's wanted here: authorize the request to Outline's own
|
||||
// `attachments.redirect`, not the presigned storage URL it 302s to.
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let data: Data
|
||||
let response: HTTPURLResponse
|
||||
do {
|
||||
(data, response) = try await httpClient.send(request)
|
||||
} catch let error as OutlineAPIError {
|
||||
throw error
|
||||
} catch {
|
||||
throw OutlineAPIError.transport(error)
|
||||
}
|
||||
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
switch response.statusCode {
|
||||
case 401:
|
||||
throw OutlineAPIError.unauthorized
|
||||
case 404:
|
||||
throw OutlineAPIError.notFound
|
||||
default:
|
||||
throw OutlineAPIError.server(status: response.statusCode, message: nil)
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func deleteAttachment(id: String) async throws {
|
||||
try await postForSuccess("attachments.delete", body: StarIDParams(id: id))
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
||||
func currentUser() async throws -> OutlineUser { throw NotStubbed() }
|
||||
func createAttachment(_ request: CreateAttachmentRequest) async throws -> CreateAttachmentResult { throw NotStubbed() }
|
||||
func uploadAttachmentFile(_ result: CreateAttachmentResult, fileData: Data) async throws { throw NotStubbed() }
|
||||
func fetchAuthenticatedFile(path: String) async throws -> Data { throw NotStubbed() }
|
||||
func deleteAttachment(id: String) async throws { throw NotStubbed() }
|
||||
func updateUserAvatar(_ request: UpdateUserAvatarRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
func updateUserName(_ request: UpdateUserNameRequest) async throws -> OutlineUser { throw NotStubbed() }
|
||||
|
||||
@@ -17,6 +17,7 @@ struct SettingsView: View {
|
||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||
@AppStorage("outpost.commandPaletteEnabled") private var isCommandPaletteEnabled = true
|
||||
@AppStorage("outpost.commandPaletteFullWorkspaceSearch") private var isCommandPaletteFullWorkspaceSearch = false
|
||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||
@@ -98,6 +99,7 @@ struct SettingsView: View {
|
||||
switch section {
|
||||
case .appearance: appearanceDetail
|
||||
case .editor: editorDetail
|
||||
case .navigation: navigationDetail
|
||||
case .profile: profileDetail
|
||||
case .preferences: preferencesDetail
|
||||
case .notifications: notificationsDetail
|
||||
@@ -188,6 +190,27 @@ struct SettingsView: View {
|
||||
|
||||
Divider().frame(maxWidth: 480)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Image Playground", isOn: $isImagePlaygroundEnabled)
|
||||
Text("Adds a \"Create Image with Image Playground\" button to the reader toolbar — generates an image from a text description (or your current selection, if any) and inserts it into the document. Requires macOS 15.1+ and a supported Mac — the toggle has no effect where it isn't available.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: 480, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Navigation
|
||||
|
||||
/// Local-only settings for finding your way around the app — separate
|
||||
/// from Editor, which is scoped to how documents are actually edited.
|
||||
private var navigationDetail: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
sectionHeader
|
||||
Text("Settings for finding documents and collections.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Toggle("Command Palette", isOn: $isCommandPaletteEnabled)
|
||||
Text("Press ⌘K to quickly jump to a document or collection. Always searches locally on your device — never a network request while typing.")
|
||||
|
||||
@@ -10,9 +10,13 @@ import OutlineKit
|
||||
/// `isSelectable` and link-opening both still need to work.
|
||||
struct CollectionOverviewContent: View {
|
||||
@State private var markdown: String
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick`.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(collection: OutlineCollection) {
|
||||
init(apiClient: OutlineAPIClient, collection: OutlineCollection) {
|
||||
_markdown = State(initialValue: collection.description ?? "")
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -20,7 +24,7 @@ struct CollectionOverviewContent: View {
|
||||
NativeTextViewWrapper(
|
||||
text: $markdown,
|
||||
configuration: .init(
|
||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
@@ -28,6 +32,12 @@ struct CollectionOverviewContent: View {
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
import OutlineKit
|
||||
|
||||
struct CollectionOverviewView: View {
|
||||
let apiClient: OutlineAPIClient
|
||||
let collection: OutlineCollection
|
||||
@State private var viewModel: DocumentsViewModel
|
||||
@State private var selectedTab: CollectionTab = .overview
|
||||
@@ -25,6 +26,7 @@ struct CollectionOverviewView: View {
|
||||
searchQuery: Binding<String>,
|
||||
onOpenDocument: @escaping (OutlineDocument) -> Void
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
self.collection = collection
|
||||
_viewModel = State(initialValue: DocumentsViewModel(apiClient: apiClient, collection: collection))
|
||||
_searchViewModel = State(initialValue: DocumentTitleSearchViewModel(apiClient: apiClient, collectionId: collection.id))
|
||||
@@ -59,7 +61,7 @@ struct CollectionOverviewView: View {
|
||||
if !trimmedSearchQuery.isEmpty {
|
||||
searchResultsList
|
||||
} else if selectedTab == .overview {
|
||||
CollectionOverviewContent(collection: collection)
|
||||
CollectionOverviewContent(apiClient: apiClient, collection: collection)
|
||||
} else {
|
||||
documentList
|
||||
}
|
||||
|
||||
@@ -17,11 +17,17 @@ struct DocumentPresentSheet: View {
|
||||
@State private var text: String
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var imageProvider: OutlineImageProvider
|
||||
/// See `DocumentReaderView`'s `imageReloadTick` — same "force an
|
||||
/// `updateNSView` re-pass so the engine notices `fingerprint()` changed"
|
||||
/// mechanism, needed here too since this sheet renders its own images.
|
||||
@State private var imageReloadTick = 0
|
||||
|
||||
init(apiClient: OutlineAPIClient, document: OutlineDocument) {
|
||||
self.apiClient = apiClient
|
||||
self.document = document
|
||||
_text = State(initialValue: document.text)
|
||||
_imageProvider = State(initialValue: OutlineImageProvider(apiClient: apiClient))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -46,7 +52,7 @@ struct DocumentPresentSheet: View {
|
||||
NativeTextViewWrapper(
|
||||
text: $text,
|
||||
configuration: .init(
|
||||
services: .init(syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
heightBehavior: .fitsContent
|
||||
),
|
||||
isEditable: false
|
||||
@@ -71,6 +77,12 @@ struct DocumentPresentSheet: View {
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 600)
|
||||
.background(.background)
|
||||
.animation(nil, value: imageReloadTick)
|
||||
.task {
|
||||
imageProvider.onImageLoaded = {
|
||||
Task { @MainActor in imageReloadTick += 1 }
|
||||
}
|
||||
}
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import UniformTypeIdentifiers
|
||||
import MarkdownEngine
|
||||
import MarkdownEngineCodeBlocks
|
||||
import OutlineKit
|
||||
#if canImport(ImagePlayground)
|
||||
import ImagePlayground
|
||||
#endif
|
||||
|
||||
/// `NSSavePanel`/`NSPrintOperation`/`NSPasteboard` in the action functions
|
||||
/// below must run on the main thread — see the identical note on
|
||||
@@ -19,6 +22,20 @@ struct DocumentReaderView: View {
|
||||
@AppStorage("outpost.splitViewEnabled") private var isSplitViewEnabled = false
|
||||
@AppStorage("outpost.autocompleteEnabled") private var isAutocompleteEnabled = true
|
||||
@AppStorage("outpost.writingToolsEnabled") private var isWritingToolsEnabled = true
|
||||
@AppStorage("outpost.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||
|
||||
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
||||
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
||||
/// eligibility) — a supported OS with an unsupported Mac still reports
|
||||
/// `false`, so this is the one check that matters, not just `#available`.
|
||||
private var isImagePlaygroundSupported: Bool {
|
||||
#if canImport(ImagePlayground)
|
||||
if #available(macOS 15.1, *) {
|
||||
return ImagePlaygroundViewController.isAvailable
|
||||
}
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
/// Outline's own "Show line numbers" preference (synced, read via
|
||||
/// `session.userPreferences`, not `@AppStorage` — this one's the
|
||||
@@ -75,7 +92,27 @@ struct DocumentReaderView: View {
|
||||
@State private var isShowingSearchSheet = false
|
||||
@State private var isShowingShareSheet = false
|
||||
@State private var isShowingNewDocumentSheet = false
|
||||
@State private var isShowingImagePlayground = false
|
||||
@State private var actionErrorMessage: String?
|
||||
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
||||
/// insert an Image Playground result's Markdown reference at the caret.
|
||||
@State private var pendingTextInsertion: TextInsertionRequest?
|
||||
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||
@State private var currentSelectedText: String?
|
||||
/// Resolves `` 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)\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
|
||||
|
||||
@@ -33,7 +33,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
|
||||
/// explicitly built yet. Content lands section by section.
|
||||
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
// General (ours)
|
||||
case appearance, editor, offlineSync, advanced, about
|
||||
case appearance, editor, navigation, offlineSync, advanced, about
|
||||
|
||||
// Account
|
||||
case profile, preferences, notifications, passkeys, apiAccess
|
||||
@@ -45,7 +45,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
|
||||
var category: SettingsCategory {
|
||||
switch self {
|
||||
case .appearance, .editor, .offlineSync, .advanced, .about:
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about:
|
||||
return .general
|
||||
case .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return .account
|
||||
@@ -58,6 +58,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
switch self {
|
||||
case .appearance: return "Appearance"
|
||||
case .editor: return "Editor"
|
||||
case .navigation: return "Navigation"
|
||||
case .offlineSync: return "Offline & Sync"
|
||||
case .advanced: return "Advanced"
|
||||
case .about: return "About"
|
||||
@@ -87,6 +88,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
switch self {
|
||||
case .appearance: return "paintbrush"
|
||||
case .editor: return "square.split.2x1"
|
||||
case .navigation: return "command"
|
||||
case .offlineSync: return "arrow.triangle.2.circlepath"
|
||||
case .advanced: return "wrench.and.screwdriver"
|
||||
case .about: return "info.circle"
|
||||
@@ -117,7 +119,7 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
|
||||
/// specified and built.
|
||||
var isImplemented: Bool {
|
||||
switch self {
|
||||
case .appearance, .editor, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
case .appearance, .editor, .navigation, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
import MarkdownEngine
|
||||
import OutlineKit
|
||||
|
||||
/// Resolves `` 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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
@@ -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
|
||||
|
||||
|
||||
+8
@@ -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 }
|
||||
|
||||
+2
@@ -84,6 +84,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
||||
var onInlineSelectionChange: ((InlineSelectionState?) -> Void)?
|
||||
var onInlinePreviewKey: ((InlinePreviewKey) -> Bool)?
|
||||
var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||
var onSelectedTextChange: ((String?) -> Void)?
|
||||
var didInitialFormatting: Bool = false
|
||||
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
||||
var didEnsureLayoutForCurrentDocument: Bool = false
|
||||
@@ -106,6 +107,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
||||
var wtUndoneDuringSession: Bool = false
|
||||
var wtPostUndoSnapshot: String?
|
||||
var lastAppliedInlineReplacementID: UUID?
|
||||
var lastAppliedTextInsertionID: UUID?
|
||||
var activeTokenIndices: Set<Int> = []
|
||||
var previousActiveTokenIndices: Set<Int> = []
|
||||
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
||||
|
||||
Vendored
+27
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -55,6 +55,10 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
/// Push a replacement into the editor by setting this to a non-nil value;
|
||||
/// the engine applies it on the next update and then clears the binding.
|
||||
@Binding public var pendingInlineReplacement: InlineReplacementRequest?
|
||||
/// Push a plain-text insertion at the caret by setting this to a non-nil
|
||||
/// value; the engine applies it on the next update and then clears the
|
||||
/// binding. See ``TextInsertionRequest``.
|
||||
@Binding public var pendingTextInsertion: TextInsertionRequest?
|
||||
/// The full editor configuration (theme + services + style toggles). Engine
|
||||
/// embedders construct this themselves and pass it in; the wrapper does
|
||||
/// not read UserDefaults or know about app-specific colors/services.
|
||||
@@ -95,6 +99,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
/// Fires when the set of visible code blocks changes, so embedders can
|
||||
/// overlay copy buttons (see ``CodeBlockButton``).
|
||||
public var onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)?
|
||||
/// Fires whenever the text selection changes. `nil` for an empty (caret-only)
|
||||
/// selection, otherwise the plain-text substring currently selected — useful
|
||||
/// for embedder features that act on "whatever's selected" (e.g. seeding a
|
||||
/// generator's prompt).
|
||||
public var onSelectedTextChange: ((String?) -> Void)?
|
||||
/// Fires after the user toggles any of the three spell/grammar/auto-correction
|
||||
/// menu items. Embedders persist the policy and pass it back via
|
||||
/// ``MarkdownEditorConfiguration/spellChecking`` on next launch.
|
||||
@@ -141,6 +150,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
text: Binding<String>,
|
||||
isWikiLinkActive: Binding<Bool> = .constant(false),
|
||||
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
||||
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
|
||||
configuration: MarkdownEditorConfiguration = .default,
|
||||
fontName: String = "SF Pro",
|
||||
fontSize: CGFloat = 16,
|
||||
@@ -153,6 +163,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
onInlineSelectionChange: ((InlineSelectionState?) -> Void)? = nil,
|
||||
onInlinePreviewKey: ((InlinePreviewKey) -> Bool)? = nil,
|
||||
onCodeBlockSelectionChange: (([CodeBlockSelection]) -> Void)? = nil,
|
||||
onSelectedTextChange: ((String?) -> Void)? = nil,
|
||||
onSpellCheckingPolicyChanged: ((SpellCheckingPolicy) -> Void)? = nil,
|
||||
placeholder: NSAttributedString? = nil,
|
||||
header: AnyView? = nil,
|
||||
@@ -166,6 +177,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
self._text = text
|
||||
self._isWikiLinkActive = isWikiLinkActive
|
||||
self._pendingInlineReplacement = pendingInlineReplacement
|
||||
self._pendingTextInsertion = pendingTextInsertion
|
||||
self.configuration = configuration
|
||||
self.fontName = fontName
|
||||
self.fontSize = fontSize
|
||||
@@ -178,6 +190,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
self.onInlineSelectionChange = onInlineSelectionChange
|
||||
self.onInlinePreviewKey = onInlinePreviewKey
|
||||
self.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
self.onSelectedTextChange = onSelectedTextChange
|
||||
self.onSpellCheckingPolicyChanged = onSpellCheckingPolicyChanged
|
||||
self.placeholder = placeholder
|
||||
self.header = header
|
||||
@@ -328,6 +341,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
|
||||
textView.recalcOverscroll(for: scrollView)
|
||||
textView.setPlaceholder(placeholder)
|
||||
@@ -541,6 +555,26 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
if fullRange.length > 0 {
|
||||
context.coordinator.restyleParagraphs([fullRange], in: textView)
|
||||
}
|
||||
// An image's display width is measured off the text container's
|
||||
// CURRENT width (see styleImageLinks/styleImageEmbeds' maxWidth),
|
||||
// which may not have settled to its real value yet in this same
|
||||
// pass — e.g. right after a text change that also grows/shrinks
|
||||
// the pane (Split View's HSplitView reflow, `.fitsContent`
|
||||
// resizing). A too-small width here falls back to a small
|
||||
// default and, with nothing else in this document changing
|
||||
// afterward, stays wrong until something else forces a restyle
|
||||
// (reopening the document). Re-measure one runloop tick later,
|
||||
// once layout has actually settled — mirrors the WideTableOverlay
|
||||
// reconciliation below.
|
||||
if imageChanged {
|
||||
let coordinator = context.coordinator
|
||||
DispatchQueue.main.async { [weak textView] in
|
||||
guard let textView else { return }
|
||||
let range = NSRange(location: 0, length: (textView.string as NSString).length)
|
||||
guard range.length > 0 else { return }
|
||||
coordinator.restyleParagraphs([range], in: textView)
|
||||
}
|
||||
}
|
||||
}
|
||||
textView.isEditable = isEditable
|
||||
textView.isSelectable = true
|
||||
@@ -562,6 +596,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
}
|
||||
return
|
||||
}
|
||||
if let pendingTextInsertion {
|
||||
if pendingTextInsertion.documentId == documentId,
|
||||
context.coordinator.lastAppliedTextInsertionID != pendingTextInsertion.id {
|
||||
context.coordinator.applyTextInsertion(pendingTextInsertion, to: textView)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if self.pendingTextInsertion?.id == pendingTextInsertion.id {
|
||||
self.pendingTextInsertion = nil
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if context.coordinator.didInitialFormatting
|
||||
&& context.coordinator.lastSyncedText == text
|
||||
&& !fontChanged {
|
||||
@@ -669,6 +715,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
context.coordinator.onInlineSelectionChange = onInlineSelectionChange
|
||||
context.coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
context.coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
context.coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
context.coordinator.didInitialFormatting = true
|
||||
}
|
||||
|
||||
@@ -691,6 +738,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
coordinator.lastImageFingerprint = configuration.services.images.fingerprint()
|
||||
coordinator.lastWikiFingerprint = configuration.services.wikiLinks.fingerprint()
|
||||
coordinator.onCodeBlockSelectionChange = onCodeBlockSelectionChange
|
||||
coordinator.onSelectedTextChange = onSelectedTextChange
|
||||
coordinator.onInlinePreviewKey = onInlinePreviewKey
|
||||
coordinator.userPrefersContinuousSpellChecking = configuration.spellChecking.continuousSpellChecking
|
||||
coordinator.userPrefersGrammarChecking = configuration.spellChecking.grammarChecking
|
||||
|
||||
Reference in New Issue
Block a user