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
|
||||
/// insert an Image Playground result's Markdown reference at the caret.
|
||||
@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;
|
||||
/// `nil` when the selection is empty (caret only, nothing highlighted).
|
||||
@State private var currentSelectedText: String?
|
||||
@@ -547,6 +550,7 @@ struct DocumentReaderView: View {
|
||||
NativeTextViewWrapper(
|
||||
text: $viewModel.text,
|
||||
pendingTextInsertion: $pendingTextInsertion,
|
||||
pendingTextRangeReplacement: $pendingCodeBlockLanguageChange,
|
||||
configuration: .init(
|
||||
services: .init(images: imageProvider, syntaxHighlighter: CodeSyntaxHighlighting.shared),
|
||||
codeBlock: editorCodeBlockStyle,
|
||||
@@ -568,6 +572,13 @@ struct DocumentReaderView: View {
|
||||
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
|
||||
CommentAnchorMarker(rect: anchor.rect) {
|
||||
focusedCommentId = anchor.id
|
||||
@@ -934,6 +945,81 @@ struct DocumentReaderView: View {
|
||||
/// Thin vertical bar next to an anchored comment's text — same visual
|
||||
/// language as Word/Google Docs' margin comment indicators. Tapping opens
|
||||
/// 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 {
|
||||
let rect: CGRect
|
||||
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
|
||||
/// pasteboard.
|
||||
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.rect = rect
|
||||
self.language = language
|
||||
self.code = code
|
||||
self.fenceRange = fenceRange
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -77,6 +77,7 @@ extension NativeTextViewCoordinator {
|
||||
guard !activeTokenIndices.contains(originalIndex) else { 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 let fenceRange = token.markerRanges.first else { return nil }
|
||||
|
||||
boundingRect.origin.x = textView.frame.origin.x + textView.textContainerOrigin.x - scrollOffset.x
|
||||
boundingRect.size.width = textContainer.containerSize.width
|
||||
@@ -85,7 +86,8 @@ extension NativeTextViewCoordinator {
|
||||
id: originalIndex,
|
||||
rect: boundingRect,
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
lastAppliedInlineReplacementID = request.id
|
||||
|
||||
|
||||
+1
@@ -110,6 +110,7 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
||||
var wtPostUndoSnapshot: String?
|
||||
var lastAppliedInlineReplacementID: UUID?
|
||||
var lastAppliedTextInsertionID: UUID?
|
||||
var lastAppliedTextRangeReplacementID: UUID?
|
||||
var activeTokenIndices: Set<Int> = []
|
||||
var previousActiveTokenIndices: Set<Int> = []
|
||||
var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:]
|
||||
|
||||
Vendored
+33
@@ -120,3 +120,36 @@ public struct TextInsertionRequest: Sendable {
|
||||
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
|
||||
/// binding. See ``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
|
||||
/// embedders construct this themselves and pass it in; the wrapper does
|
||||
/// not read UserDefaults or know about app-specific colors/services.
|
||||
@@ -159,6 +164,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
isWikiLinkActive: Binding<Bool> = .constant(false),
|
||||
pendingInlineReplacement: Binding<InlineReplacementRequest?> = .constant(nil),
|
||||
pendingTextInsertion: Binding<TextInsertionRequest?> = .constant(nil),
|
||||
pendingTextRangeReplacement: Binding<TextRangeReplacementRequest?> = .constant(nil),
|
||||
configuration: MarkdownEditorConfiguration = .default,
|
||||
fontName: String = "SF Pro",
|
||||
fontSize: CGFloat = 16,
|
||||
@@ -188,6 +194,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
self._isWikiLinkActive = isWikiLinkActive
|
||||
self._pendingInlineReplacement = pendingInlineReplacement
|
||||
self._pendingTextInsertion = pendingTextInsertion
|
||||
self._pendingTextRangeReplacement = pendingTextRangeReplacement
|
||||
self.configuration = configuration
|
||||
self.fontName = fontName
|
||||
self.fontSize = fontSize
|
||||
@@ -636,6 +643,18 @@ public struct NativeTextViewWrapper: NSViewRepresentable {
|
||||
}
|
||||
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
|
||||
&& context.coordinator.lastSyncedText == text
|
||||
&& !fontChanged {
|
||||
|
||||
Reference in New Issue
Block a user