From 08bc0cb3841e75952c0d8193923b5cf6c17ce0c6 Mon Sep 17 00:00:00 2001 From: Axel Andersson Date: Mon, 18 May 2026 12:38:11 +0200 Subject: [PATCH 1/2] Decouple isSelectable from isEditable in NativeTextViewWrapper Setting `isEditable: false` previously also forced `isSelectable = false` on the underlying NSTextView, which makes NSTextView skip link hit-testing and disables text selection. Read-only markdown views could not open URLs in the browser, fire `onLinkClick` for wiki links, or copy text. `isSelectable` is now always true; `isEditable` controls only the caret and editing, as documented. Fixes #31. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 3 +++ Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd68d7..1438712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `MarkdownEditorConfiguration.safeAreaInsets`. ### Fixed +- `NativeTextViewWrapper` keeps links clickable and text selectable + when `isEditable: false`; `isSelectable` is no longer coupled to + `isEditable`. (#31) - `NativeTextViewWrapper` now applies its initial styling pass even when the bound text starts at its final value (e.g. supplied as a SwiftUI `@State` initializer). Previously the editor would render the raw diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift index 0f99802..841c8f3 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewWrapper.swift @@ -140,7 +140,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { context.coordinator.configuration = configuration textView.insertionPointColor = configuration.theme.bodyText textView.isEditable = isEditable - textView.isSelectable = isEditable + textView.isSelectable = true textView.isRichText = true let initialState = WikiLinkService.makeDisplayState(from: text) textView.string = initialState.display @@ -264,7 +264,7 @@ public struct NativeTextViewWrapper: NSViewRepresentable { (nsView.documentView as? NativeTextView)?.configuration.services = configuration.services } textView.isEditable = isEditable - textView.isSelectable = isEditable + textView.isSelectable = true textView.insertionPointColor = isEditable ? context.coordinator.configuration.theme.bodyText : .clear let fontChanged = (context.coordinator.fontName != fontName) || (context.coordinator.fontSize != fontSize) if let pendingInlineReplacement { From 8f4aa60b0e2a362d4ec7fbe5bc400d53e2143aa6 Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Wed, 20 May 2026 20:24:39 +0200 Subject: [PATCH 2/2] fix: suppress active-token reveal when isEditable is false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only mode (isEditable: false) hides the caret but NSTextView still updates selectedRange on click. The engine's active-token logic was caret-driven, so clicking a heading / code / task / HR line still revealed its raw markdown syntax — half-read-only. - `computeActiveTokenIndices` gains a `suppressed: Bool = false` flag; when true it returns an empty set - The 5 coordinator call sites pass `suppressed: !textView.isEditable` - Styling calls forward `caretLocation: -1` when read-only so the two paths that bypass activeTokenIndices (task-checkbox reveal, HR-line-caret reveal) also stay silent Co-Authored-By: Claude Opus 4.7 (1M context) --- .../MarkdownEngine/Parser/MarkdownDetection.swift | 7 ++++++- .../NativeTextViewCoordinator+Restyling.swift | 13 +++++++++---- .../NativeTextViewCoordinator+TextDelegate.swift | 8 +++++--- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift index df252bd..ca76043 100644 --- a/Sources/MarkdownEngine/Parser/MarkdownDetection.swift +++ b/Sources/MarkdownEngine/Parser/MarkdownDetection.swift @@ -16,8 +16,13 @@ enum MarkdownDetection { static func computeActiveTokenIndices( selectionRange: NSRange, tokens: [MarkdownToken], - in text: NSString + in text: NSString, + suppressed: Bool = false ) -> Set { + // In read-only mode (no caret) we never want to reveal raw markdown + // syntax — `isEditable: false` should hide all tokens regardless of + // the trailing selection NSTextView keeps around after a click. + if suppressed { return [] } var indices: Set = [] let caretLocation = selectionRange.location for (index, token) in tokens.enumerated() { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 8b56f11..003eb95 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -46,11 +46,15 @@ extension NativeTextViewCoordinator { textView.textStorage?.setAttributes(baseAttrs, range: fullRange) let tokens = parsedDocument(for: displayText).tokens - let caretLocation = textView.selectedRange().location + // Hide the caret position from styling when the view is read-only, + // otherwise clicks reveal raw token syntax (#, `, etc.) even though + // the user can't edit. + let caretLocation = textView.isEditable ? textView.selectedRange().location : -1 activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( selectionRange: textView.selectedRange(), tokens: tokens, - in: nsDisplay + in: nsDisplay, + suppressed: !textView.isEditable ) let ranges = MarkdownStyler.styleAttributes( @@ -102,7 +106,7 @@ extension NativeTextViewCoordinator { paragraphCandidates: paragraphCandidates, baseFont: baseFont, paragraphStyle: paragraphStyle, - caretLocation: textView.selectedRange().location, + caretLocation: textView.isEditable ? textView.selectedRange().location : -1, activeTokenIndices: activeTokenIndices, wikiLinkIDProvider: { [weak self] range in self?.wikiLinkID(for: range) @@ -217,7 +221,8 @@ extension NativeTextViewCoordinator { activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( selectionRange: textView.selectedRange(), tokens: tokens, - in: nsText + in: nsText, + suppressed: !textView.isEditable ) restyleTextView(textView, paragraphCandidates: paragraphs, tokens: tokens) } diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 5a68e32..31935a6 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -118,7 +118,8 @@ extension NativeTextViewCoordinator { activeTokenIndices = MarkdownDetection.computeActiveTokenIndices( selectionRange: safeSelRange, tokens: tokens, - in: fullText + in: fullText, + suppressed: !tv.isEditable ) filterImageEmbedActiveTokens(parsed: parsed, text: fullText, selectionLocation: safeSelRange.location) updateAutocorrectSettings( @@ -182,7 +183,7 @@ extension NativeTextViewCoordinator { let nsText = tv.string as NSString let prevActive = activeTokenIndices - activeTokenIndices = MarkdownDetection.computeActiveTokenIndices(selectionRange: selRange, tokens: tokens, in: nsText) + activeTokenIndices = MarkdownDetection.computeActiveTokenIndices(selectionRange: selRange, tokens: tokens, in: nsText, suppressed: !tv.isEditable) filterImageEmbedActiveTokens(parsed: parsed, text: nsText, selectionLocation: selRange.location) updateAutocorrectSettings( tv, @@ -323,7 +324,8 @@ extension NativeTextViewCoordinator { pendingPreEditActiveTokenIndices = MarkdownDetection.computeActiveTokenIndices( selectionRange: textView.selectedRange(), tokens: parsed.tokens, - in: textView.string as NSString + in: textView.string as NSString, + suppressed: !textView.isEditable ) // Block LaTeX auto-wrap: insert newlines to keep $$ on its own line