feat: code block language picker
Top-left pill overlay on every code block (opposite corner from the engine's own copy button) showing the current language; tapping opens a curated ~20-language menu that rewrites just the fence line, content untouched. New engine mechanism to support it: CodeBlockSelection now exposes fenceRange (the opening ```lang line's range, straight from the tokenizer's own markerRanges[0] - was already computed, just never exposed) plus a generic TextRangeReplacementRequest/ pendingTextRangeReplacement, since the existing pendingTextInsertion only replaces at the current caret/selection, not an arbitrary already-known range like a fence line the user isn't necessarily positioned at. Horizontal scroll for long lines investigated and dropped, per explicit instruction after weighing it: the only viable path (a real, selectable/editable independently-scrolling text region) needs an embedded NSTextAttachmentViewProvider-backed NSTextView per code block, which would split selection/copy/undo into two contexts (the code block's own vs. the surrounding document's) instead of one unified editing surface - a different regression, not a clean win. Code blocks keep wrapping.
This commit is contained in:
@@ -123,6 +123,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,6 +550,7 @@ 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,
|
||||||
@@ -568,6 +572,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 +945,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
|
||||||
|
|||||||
+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] = [:]
|
||||||
|
|||||||
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