diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index b39a3bd..1d1a3e5 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -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 diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CodeBlockButton.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CodeBlockButton.swift index 1e2c55e..96bc519 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CodeBlockButton.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/CodeBlockButton.swift @@ -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 } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index cfffe41..01e38f9 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -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 ) } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 642a81b..0d01955 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -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 diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index fa8111b..e7c9471 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -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 = [] var previousActiveTokenIndices: Set = [] var wikiLinkMetadata: [WikiLinkService.RangeKey: WikiLinkService.LinkMetadata] = [:] diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift index 26a1f95..dee9efc 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift @@ -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 + } +} diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 096ea0d..8b830da 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -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 = .constant(false), pendingInlineReplacement: Binding = .constant(nil), pendingTextInsertion: Binding = .constant(nil), + pendingTextRangeReplacement: Binding = .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 {