Compare commits
3
Commits
2d0972de1e
...
c36c70382b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36c70382b
|
||
|
|
f01c2b9467
|
||
|
|
283186b28f
|
@@ -18,6 +18,7 @@ struct SettingsView: View {
|
|||||||
@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.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||||
|
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = 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
|
||||||
@@ -197,6 +198,16 @@ struct SettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 480, alignment: .leading)
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
||||||
|
Divider().frame(maxWidth: 480)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Toggle("Pointer Cursor", isOn: $isPointerCursorEnabled)
|
||||||
|
Text("Show a pointing-hand cursor when hovering sidebar rows and links in a document, instead of the default arrow or I-beam.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.frame(width: 12)
|
.frame(width: 12)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -186,6 +187,7 @@ private struct DocumentNodeRow: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ struct CollectionListContent: View {
|
|||||||
CollectionRowView(collection: collection)
|
CollectionRowView(collection: collection)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ struct CollectionOverviewView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
@@ -152,6 +153,7 @@ struct CollectionOverviewView: View {
|
|||||||
DocumentRowView(document: document)
|
DocumentRowView(document: document)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +186,7 @@ struct CollectionOverviewView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ struct CollectionTreeRow: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
.animation(.easeInOut(duration: 0.15), value: isExpanded)
|
||||||
.contextMenu { contextMenuContent }
|
.contextMenu { contextMenuContent }
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct CommandPaletteView: View {
|
|||||||
resultRow(result, isSelected: index == selectedIndex)
|
resultRow(result, isSelected: index == selectedIndex)
|
||||||
.id(index)
|
.id(index)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
|
.pointerCursorOnHover()
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
selectedIndex = index
|
selectedIndex = index
|
||||||
selectCurrent()
|
selectCurrent()
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct DocumentReaderView: View {
|
|||||||
@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.imagePlaygroundEnabled") private var isImagePlaygroundEnabled = true
|
||||||
|
@AppStorage("outpost.pointerCursorEnabled") private var isPointerCursorEnabled = true
|
||||||
|
|
||||||
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
/// `ImagePlaygroundViewController.isAvailable` gates on both OS version
|
||||||
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
/// (macOS 15.1+) and actual device/region support (Apple Intelligence
|
||||||
@@ -123,6 +124,9 @@ struct DocumentReaderView: View {
|
|||||||
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
/// Pushed into the main editable pane's `NativeTextViewWrapper` to
|
||||||
/// insert an Image Playground result's Markdown reference at the caret.
|
/// insert an Image Playground result's Markdown reference at the caret.
|
||||||
@State private var pendingTextInsertion: TextInsertionRequest?
|
@State private var pendingTextInsertion: TextInsertionRequest?
|
||||||
|
/// Pushed by `CodeBlockLanguagePicker` to rewrite a code block's fence
|
||||||
|
/// line when the user switches its language from the picker.
|
||||||
|
@State private var pendingCodeBlockLanguageChange: TextRangeReplacementRequest?
|
||||||
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
/// Live-updated by `onSelectedTextChange` on the main editable pane;
|
||||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||||
@State private var currentSelectedText: String?
|
@State private var currentSelectedText: String?
|
||||||
@@ -547,13 +551,15 @@ struct DocumentReaderView: View {
|
|||||||
NativeTextViewWrapper(
|
NativeTextViewWrapper(
|
||||||
text: $viewModel.text,
|
text: $viewModel.text,
|
||||||
pendingTextInsertion: $pendingTextInsertion,
|
pendingTextInsertion: $pendingTextInsertion,
|
||||||
|
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
|
||||||
configuration: .init(
|
configuration: .init(
|
||||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||||
codeBlock: editorCodeBlockStyle,
|
codeBlock: editorCodeBlockStyle,
|
||||||
textSubstitution: editorTextSubstitution,
|
textSubstitution: editorTextSubstitution,
|
||||||
textCompletion: editorTextCompletion,
|
textCompletion: editorTextCompletion,
|
||||||
writingTools: editorWritingTools,
|
writingTools: editorWritingTools,
|
||||||
heightBehavior: .fitsContent
|
heightBehavior: .fitsContent,
|
||||||
|
pointerCursorOverLinksWhileEditing: isPointerCursorEnabled
|
||||||
),
|
),
|
||||||
documentId: viewModel.documentId,
|
documentId: viewModel.documentId,
|
||||||
isEditable: viewModel.isEffectivelyEditable,
|
isEditable: viewModel.isEffectivelyEditable,
|
||||||
@@ -568,6 +574,13 @@ struct DocumentReaderView: View {
|
|||||||
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
CodeBlockLineNumberGutter(selection: selection, gutterWidth: Self.lineNumberGutterWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if viewModel.isEffectivelyEditable {
|
||||||
|
ForEach(readerCodeBlocks) { selection in
|
||||||
|
CodeBlockLanguagePicker(selection: selection, documentId: viewModel.documentId) { request in
|
||||||
|
pendingCodeBlockLanguageChange = request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
ForEach(commentAnchorRects) { anchor in
|
ForEach(commentAnchorRects) { anchor in
|
||||||
CommentAnchorMarker(rect: anchor.rect) {
|
CommentAnchorMarker(rect: anchor.rect) {
|
||||||
focusedCommentId = anchor.id
|
focusedCommentId = anchor.id
|
||||||
@@ -934,6 +947,81 @@ struct DocumentReaderView: View {
|
|||||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||||
/// the comments sheet focused to that thread.
|
/// the comments sheet focused to that thread.
|
||||||
|
/// Top-left pill overlay on each code block — opposite corner from the
|
||||||
|
/// engine's own copy button (top-right), same `Color.clear` + `.position()`
|
||||||
|
/// sizing trick `CodeBlockButton` uses. Tapping opens a curated language
|
||||||
|
/// list (not the full ~190-language highlight.js catalog — same "small
|
||||||
|
/// curated set, not the whole thing" call as the emoji reaction picker);
|
||||||
|
/// picking one rewrites just the fence line via
|
||||||
|
/// `CodeBlockSelection.fenceRange`, leaving the block's content untouched.
|
||||||
|
private struct CodeBlockLanguagePicker: View {
|
||||||
|
let selection: CodeBlockSelection
|
||||||
|
let documentId: String
|
||||||
|
let onRequest: (TextRangeReplacementRequest) -> Void
|
||||||
|
|
||||||
|
private static let languages: [(label: String, tag: String?)] = [
|
||||||
|
("Plain Text", nil),
|
||||||
|
("Swift", "swift"),
|
||||||
|
("Python", "python"),
|
||||||
|
("JavaScript", "javascript"),
|
||||||
|
("TypeScript", "typescript"),
|
||||||
|
("Bash", "bash"),
|
||||||
|
("JSON", "json"),
|
||||||
|
("YAML", "yaml"),
|
||||||
|
("HTML", "html"),
|
||||||
|
("CSS", "css"),
|
||||||
|
("SQL", "sql"),
|
||||||
|
("Java", "java"),
|
||||||
|
("Kotlin", "kotlin"),
|
||||||
|
("C", "c"),
|
||||||
|
("C++", "cpp"),
|
||||||
|
("C#", "csharp"),
|
||||||
|
("Go", "go"),
|
||||||
|
("Rust", "rust"),
|
||||||
|
("Ruby", "ruby"),
|
||||||
|
("PHP", "php"),
|
||||||
|
("Markdown", "markdown"),
|
||||||
|
]
|
||||||
|
|
||||||
|
private var currentLabel: String {
|
||||||
|
guard let language = selection.language, !language.isEmpty else { return "Plain Text" }
|
||||||
|
return Self.languages.first { $0.tag == language }?.label ?? language.uppercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: selection.rect.width, height: selection.rect.height)
|
||||||
|
.overlay(alignment: .topLeading) {
|
||||||
|
Menu {
|
||||||
|
ForEach(Self.languages, id: \.label) { entry in
|
||||||
|
Button(entry.label) {
|
||||||
|
onRequest(TextRangeReplacementRequest(
|
||||||
|
documentId: documentId,
|
||||||
|
range: selection.fenceRange,
|
||||||
|
replacement: "```\(entry.tag ?? "")\n"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(currentLabel.uppercased())
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.black.opacity(0.3), in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.menuStyle(.borderlessButton)
|
||||||
|
.fixedSize()
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
.position(
|
||||||
|
x: selection.rect.midX,
|
||||||
|
y: selection.rect.midY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct CommentAnchorMarker: View {
|
private struct CommentAnchorMarker: View {
|
||||||
let rect: CGRect
|
let rect: CGRect
|
||||||
let onTap: () -> Void
|
let onTap: () -> Void
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ struct HomeView: View {
|
|||||||
PinnedDocumentCard(document: document)
|
PinnedDocumentCard(document: document)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -2,45 +2,57 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import OutlineKit
|
import OutlineKit
|
||||||
|
|
||||||
/// Deliberately distinct from `DocumentCardView` — the pinned section is for
|
/// Same tall-card shape as `DocumentCardView` (the tab grids below it) so the
|
||||||
/// a quick scan of a small curated set, not browsing, so this is a dense
|
/// pinned section reads as a set of cards, not a row of tab-like chips — the
|
||||||
/// single-line row rather than a tall card, with an explicit pin glyph so
|
/// accent-filled pin badge is the one thing that marks these as pinned.
|
||||||
/// it doesn't read the same as the tab grids below it.
|
|
||||||
struct PinnedDocumentCard: View {
|
struct PinnedDocumentCard: View {
|
||||||
@Environment(StarStore.self) private var starStore
|
@Environment(StarStore.self) private var starStore
|
||||||
let document: OutlineDocument
|
let document: OutlineDocument
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 10) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
if let emoji = document.emoji {
|
HStack(spacing: 6) {
|
||||||
Text(emoji)
|
if let emoji = document.emoji {
|
||||||
.font(.title3)
|
Text(emoji)
|
||||||
} else {
|
.font(.title2)
|
||||||
Image(systemName: "doc.text")
|
} else {
|
||||||
.font(.body)
|
Image(systemName: "doc.text")
|
||||||
.foregroundStyle(.secondary)
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if starStore.isStarred(documentId: document.id) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
}
|
||||||
|
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(5)
|
||||||
|
.background(Color.accentColor, in: Circle())
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(document.title.isEmpty ? "Untitled" : document.title)
|
Text(document.title.isEmpty ? "Untitled" : document.title)
|
||||||
.font(.callout.weight(.medium))
|
.font(.headline)
|
||||||
.lineLimit(1)
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Text(document.updatedAt, format: .relative(presentation: .named))
|
||||||
|
.font(.caption)
|
||||||
if starStore.isStarred(documentId: document.id) {
|
|
||||||
Image(systemName: "star.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.yellow)
|
|
||||||
}
|
|
||||||
|
|
||||||
Image(systemName: "pin.fill")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(14)
|
||||||
.padding(.vertical, 9)
|
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 12))
|
||||||
.background(.fill.tertiary, in: RoundedRectangle(cornerRadius: 8))
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct GlobalSearchResultsView: View {
|
|||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.pointerCursorOnHover()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Pointing-hand cursor while the mouse is over a clickable sidebar/list row,
|
||||||
|
/// gated by the "Pointer Cursor" setting (Settings → Editor → Pointer Cursor).
|
||||||
|
/// `didPush` tracks whether this instance actually pushed a cursor, so a
|
||||||
|
/// mid-hover toggle of the preference can never leave `NSCursor`'s push/pop
|
||||||
|
/// stack unbalanced — pop only fires for a push this same hover made.
|
||||||
|
private struct PointerCursorOnHover: ViewModifier {
|
||||||
|
@AppStorage("outpost.pointerCursorEnabled") private var isEnabled = true
|
||||||
|
@State private var didPush = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.onHover { hovering in
|
||||||
|
if hovering {
|
||||||
|
guard isEnabled else { return }
|
||||||
|
NSCursor.pointingHand.push()
|
||||||
|
didPush = true
|
||||||
|
} else if didPush {
|
||||||
|
NSCursor.pop()
|
||||||
|
didPush = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func pointerCursorOnHover() -> some View {
|
||||||
|
modifier(PointerCursorOnHover())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
extension View {
|
||||||
|
func pointerCursorOnHover() -> some View { self }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
Vendored
+11
-1
@@ -94,6 +94,14 @@ public struct MarkdownEditorConfiguration: Sendable {
|
|||||||
/// so this stays the embedder's explicit decision rather than something the
|
/// so this stays the embedder's explicit decision rather than something the
|
||||||
/// engine infers from a color it happens to see.
|
/// engine infers from a color it happens to see.
|
||||||
public var cursorFollowsSpanInk: Bool
|
public var cursorFollowsSpanInk: Bool
|
||||||
|
/// Show a pointing-hand cursor over links while editing (not just in
|
||||||
|
/// read-only mode, where it always shows regardless of this flag).
|
||||||
|
///
|
||||||
|
/// On by default. The embedder's "pointer cursor" preference maps
|
||||||
|
/// straight to this — off just means the I-beam stays over links like
|
||||||
|
/// any other text, since a link's edge zone still repositions the caret
|
||||||
|
/// for editing rather than navigating (see `clickedOnLink`).
|
||||||
|
public var pointerCursorOverLinksWhileEditing: Bool
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
theme: MarkdownEditorTheme = .default,
|
theme: MarkdownEditorTheme = .default,
|
||||||
@@ -123,7 +131,8 @@ public struct MarkdownEditorConfiguration: Sendable {
|
|||||||
heightBehavior: HeightBehavior = .scrolls,
|
heightBehavior: HeightBehavior = .scrolls,
|
||||||
rawSourceMode: Bool = false,
|
rawSourceMode: Bool = false,
|
||||||
extensions: [any MarkdownExtension] = [],
|
extensions: [any MarkdownExtension] = [],
|
||||||
cursorFollowsSpanInk: Bool = false
|
cursorFollowsSpanInk: Bool = false,
|
||||||
|
pointerCursorOverLinksWhileEditing: Bool = true
|
||||||
) {
|
) {
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.services = services
|
self.services = services
|
||||||
@@ -153,6 +162,7 @@ public struct MarkdownEditorConfiguration: Sendable {
|
|||||||
self.rawSourceMode = rawSourceMode
|
self.rawSourceMode = rawSourceMode
|
||||||
self.extensions = extensions
|
self.extensions = extensions
|
||||||
self.cursorFollowsSpanInk = cursorFollowsSpanInk
|
self.cursorFollowsSpanInk = cursorFollowsSpanInk
|
||||||
|
self.pointerCursorOverLinksWhileEditing = pointerCursorOverLinksWhileEditing
|
||||||
}
|
}
|
||||||
|
|
||||||
public static let `default` = MarkdownEditorConfiguration()
|
public static let `default` = MarkdownEditorConfiguration()
|
||||||
|
|||||||
+8
-1
@@ -24,12 +24,19 @@ public struct CodeBlockSelection: Identifiable, Sendable {
|
|||||||
/// Plain text content of the block, suitable for putting on the
|
/// Plain text content of the block, suitable for putting on the
|
||||||
/// pasteboard.
|
/// pasteboard.
|
||||||
public let code: String
|
public let code: String
|
||||||
|
/// Range of the opening fence line (`` ```lang\n `` or `` ```\n ``) in
|
||||||
|
/// the text view's DISPLAY-form string — `token.markerRanges[0]` from
|
||||||
|
/// the parser. Lets an embedder safely rewrite just the language tag
|
||||||
|
/// (e.g. a language-picker menu) without touching the block's content,
|
||||||
|
/// which `code`/`rect` alone don't give enough to do.
|
||||||
|
public let fenceRange: NSRange
|
||||||
|
|
||||||
public init(id: Int, rect: CGRect, language: String?, code: String) {
|
public init(id: Int, rect: CGRect, language: String?, code: String, fenceRange: NSRange) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.rect = rect
|
self.rect = rect
|
||||||
self.language = language
|
self.language = language
|
||||||
self.code = code
|
self.code = code
|
||||||
|
self.fenceRange = fenceRange
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -77,6 +77,7 @@ extension NativeTextViewCoordinator {
|
|||||||
guard !activeTokenIndices.contains(originalIndex) else { return nil }
|
guard !activeTokenIndices.contains(originalIndex) else { return nil }
|
||||||
if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil }
|
if let visibleRange, NSIntersectionRange(token.range, visibleRange).length == 0 { return nil }
|
||||||
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
|
guard var boundingRect = textView.viewRect(forCharacterRange: token.range, using: layoutBridge) else { return nil }
|
||||||
|
guard let fenceRange = token.markerRanges.first else { return nil }
|
||||||
|
|
||||||
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
|
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
|
||||||
boundingRect.size.width = textContainer.containerSize.width
|
boundingRect.size.width = textContainer.containerSize.width
|
||||||
@@ -85,7 +86,8 @@ extension NativeTextViewCoordinator {
|
|||||||
id: originalIndex,
|
id: originalIndex,
|
||||||
rect: boundingRect,
|
rect: boundingRect,
|
||||||
language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string),
|
language: MarkdownTokenizer.extractLanguage(from: token, in: textView.string),
|
||||||
code: nsText.substring(with: token.contentRange)
|
code: nsText.substring(with: token.contentRange),
|
||||||
|
fenceRange: fenceRange
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
@@ -469,6 +469,34 @@ extension NativeTextViewCoordinator {
|
|||||||
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
|
textView.setSelectedRange(NSRange(location: caretLocation, length: 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces `request.range` verbatim — unlike `applyTextInsertion`, does
|
||||||
|
/// NOT touch the current caret/selection at all, since the range being
|
||||||
|
/// replaced (e.g. a code block's fence line) usually isn't wherever the
|
||||||
|
/// caret happens to be. No-ops if `range` no longer fits inside the
|
||||||
|
/// current document (the doc changed shape since the request was built
|
||||||
|
/// — safer to drop the edit than risk corrupting an unrelated range).
|
||||||
|
func applyTextRangeReplacement(_ request: TextRangeReplacementRequest, to textView: NSTextView) {
|
||||||
|
lastAppliedTextRangeReplacementID = request.id
|
||||||
|
|
||||||
|
let documentLength = (textView.string as NSString).length
|
||||||
|
guard request.range.location != NSNotFound,
|
||||||
|
NSMaxRange(request.range) <= documentLength else { return }
|
||||||
|
|
||||||
|
textView.breakUndoCoalescing()
|
||||||
|
|
||||||
|
isProgrammaticEdit = true
|
||||||
|
defer { isProgrammaticEdit = false }
|
||||||
|
|
||||||
|
guard textView.shouldChangeText(in: request.range, replacementString: request.replacement) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
textView.textStorage?.replaceCharacters(in: request.range, with: request.replacement)
|
||||||
|
textView.didChangeText()
|
||||||
|
textView.undoManager?.setActionName("Change Code Block Language")
|
||||||
|
textView.breakUndoCoalescing()
|
||||||
|
}
|
||||||
|
|
||||||
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
|
func applyInlineReplacement(_ request: InlineReplacementRequest, to textView: NSTextView) {
|
||||||
lastAppliedInlineReplacementID = request.id
|
lastAppliedInlineReplacementID = request.id
|
||||||
|
|
||||||
|
|||||||
+1
@@ -110,6 +110,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var wtPostUndoSnapshot: String?
|
var wtPostUndoSnapshot: String?
|
||||||
var lastAppliedInlineReplacementID: UUID?
|
var lastAppliedInlineReplacementID: UUID?
|
||||||
var lastAppliedTextInsertionID: UUID?
|
var lastAppliedTextInsertionID: UUID?
|
||||||
|
var lastAppliedTextRangeReplacementID: 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] = [:]
|
||||||
|
|||||||
+9
-3
@@ -75,11 +75,17 @@ extension NativeTextView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// In read-only mode, override NSTextView's I-beam: pointing hand over a
|
/// In read-only mode, override NSTextView's I-beam: pointing hand over a
|
||||||
/// `.link` range, arrow everywhere else.
|
/// `.link` range, arrow everywhere else. In edit mode, only the pointing
|
||||||
|
/// hand part applies (gated on the embedder's preference) — everywhere
|
||||||
|
/// else keeps the I-beam, which `super` already set.
|
||||||
private func applyReadOnlyCursor(for event: NSEvent) {
|
private func applyReadOnlyCursor(for event: NSEvent) {
|
||||||
guard isSelectable, !isEditable else { return } // edit mode: keep I-beam
|
guard isSelectable else { return }
|
||||||
let viewPoint = convert(event.locationInWindow, from: nil)
|
let viewPoint = convert(event.locationInWindow, from: nil)
|
||||||
if isOverLink(at: viewPoint) {
|
if isEditable {
|
||||||
|
guard configuration.pointerCursorOverLinksWhileEditing,
|
||||||
|
isOverLink(at: viewPoint) else { return } // keep I-beam
|
||||||
|
NSCursor.pointingHand.set()
|
||||||
|
} else if isOverLink(at: viewPoint) {
|
||||||
NSCursor.pointingHand.set()
|
NSCursor.pointingHand.set()
|
||||||
} else {
|
} else {
|
||||||
NSCursor.arrow.set()
|
NSCursor.arrow.set()
|
||||||
|
|||||||
Vendored
+33
@@ -120,3 +120,36 @@ public struct TextInsertionRequest: Sendable {
|
|||||||
self.text = text
|
self.text = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request to replace a SPECIFIC, already-known range — e.g. a code block's
|
||||||
|
/// fence line (``CodeBlockSelection/fenceRange``) when switching its
|
||||||
|
/// language — regardless of where the caret currently is. Unlike
|
||||||
|
/// ``TextInsertionRequest``, this doesn't touch the current selection at
|
||||||
|
/// all.
|
||||||
|
///
|
||||||
|
/// Embedders push one of these into
|
||||||
|
/// ``NativeTextViewWrapper/pendingTextRangeReplacement`` to commit it. The
|
||||||
|
/// range is in DISPLAY-form coordinates (the same space `CodeBlockSelection`
|
||||||
|
/// and every other rect/range the engine hands back use) — stale by the
|
||||||
|
/// time it's applied only if the document changed shape in between, in
|
||||||
|
/// which case the engine no-ops rather than risk corrupting an unrelated
|
||||||
|
/// range.
|
||||||
|
public struct TextRangeReplacementRequest: Sendable {
|
||||||
|
/// Stable identifier so the engine can detect already-applied requests
|
||||||
|
/// across SwiftUI re-renders.
|
||||||
|
public let id: UUID
|
||||||
|
/// Document the replacement targets. Ignored if it doesn't match the
|
||||||
|
/// editor's current `documentId` (prevents cross-document writes).
|
||||||
|
public let documentId: String
|
||||||
|
/// Range to replace, in the display-form string's coordinates.
|
||||||
|
public let range: NSRange
|
||||||
|
/// Text to put in `range`'s place.
|
||||||
|
public let replacement: String
|
||||||
|
|
||||||
|
public init(id: UUID = UUID(), documentId: String, range: NSRange, replacement: String) {
|
||||||
|
self.id = id
|
||||||
|
self.documentId = documentId
|
||||||
|
self.range = range
|
||||||
|
self.replacement = replacement
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+19
@@ -59,6 +59,11 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
/// value; the engine applies it on the next update and then clears the
|
/// value; the engine applies it on the next update and then clears the
|
||||||
/// binding. See ``TextInsertionRequest``.
|
/// binding. See ``TextInsertionRequest``.
|
||||||
@Binding public var pendingTextInsertion: TextInsertionRequest?
|
@Binding public var pendingTextInsertion: TextInsertionRequest?
|
||||||
|
/// Push a specific-range replacement (e.g. rewriting a code block's
|
||||||
|
/// fence line to change its language) by setting this to a non-nil
|
||||||
|
/// value; the engine applies it on the next update and then clears the
|
||||||
|
/// binding. See ``TextRangeReplacementRequest``.
|
||||||
|
@Binding public var pendingTextRangeReplacement: TextRangeReplacementRequest?
|
||||||
/// 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.
|
||||||
@@ -159,6 +164,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
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),
|
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
|
||||||
|
pendingTextRangeReplacement: Binding<TextRangeReplacementRequest?> = .constant(nil),
|
||||||
configuration: MarkdownEditorConfiguration = .default,
|
configuration: MarkdownEditorConfiguration = .default,
|
||||||
fontName: String = "SF Pro",
|
fontName: String = "SF Pro",
|
||||||
fontSize: CGFloat = 16,
|
fontSize: CGFloat = 16,
|
||||||
@@ -188,6 +194,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
self._isWikiLinkActive = isWikiLinkActive
|
self._isWikiLinkActive = isWikiLinkActive
|
||||||
self._pendingInlineReplacement = pendingInlineReplacement
|
self._pendingInlineReplacement = pendingInlineReplacement
|
||||||
self._pendingTextInsertion = pendingTextInsertion
|
self._pendingTextInsertion = pendingTextInsertion
|
||||||
|
self._pendingTextRangeReplacement = pendingTextRangeReplacement
|
||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.fontName = fontName
|
self.fontName = fontName
|
||||||
self.fontSize = fontSize
|
self.fontSize = fontSize
|
||||||
@@ -636,6 +643,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if let pendingTextRangeReplacement {
|
||||||
|
if pendingTextRangeReplacement.documentId == documentId,
|
||||||
|
context.coordinator.lastAppliedTextRangeReplacementID != pendingTextRangeReplacement.id {
|
||||||
|
context.coordinator.applyTextRangeReplacement(pendingTextRangeReplacement, to: textView)
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if self.pendingTextRangeReplacement?.id == pendingTextRangeReplacement.id {
|
||||||
|
self.pendingTextRangeReplacement = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if context.coordinator.didInitialFormatting
|
if context.coordinator.didInitialFormatting
|
||||||
&& context.coordinator.lastSyncedText == text
|
&& context.coordinator.lastSyncedText == text
|
||||||
&& !fontChanged {
|
&& !fontChanged {
|
||||||
|
|||||||
Reference in New Issue
Block a user