From 369e12cf827d444da0a328100b7503bb98813c53 Mon Sep 17 00:00:00 2001 From: Christine Tham Date: Sat, 11 Jul 2026 17:35:27 +1000 Subject: [PATCH] Add #tag inline-selection kind for tag autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `.tag` InlineSelectionKind and caret-scan detection: when the caret is inside a `#tag` (≥1 body char, `#` at a whitespace/line boundary so headings and mid-word `#` don't match, not in code), the engine fires onInlineSelectionChange(.tag) with the tag text + a caret rect, mirroring how it drives wiki-link autocomplete. Also adds an `isLiteralMode` to InlineReplacementRequest so a host can commit a plain `#tag ` verbatim (caret at end) without wiki-link `[[Name|id]]` parsing. Co-Authored-By: Claude Opus 4.8 --- ...eTextViewCoordinator+InlineSelection.swift | 43 +++++++++++++++++++ .../NativeTextViewCoordinator+Restyling.swift | 30 ++++++++----- ...tiveTextViewCoordinator+TextDelegate.swift | 14 ++++++ .../NativeTextViewSelectionTypes.swift | 11 ++++- 4 files changed, 87 insertions(+), 11 deletions(-) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+InlineSelection.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+InlineSelection.swift index 65dae979..73d70406 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+InlineSelection.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+InlineSelection.swift @@ -93,6 +93,49 @@ extension NativeTextViewCoordinator { return nil } + // MARK: - Tag detection (#tag autocomplete) + + /// If the caret sits inside a `#tag` (at least one body character after the + /// `#`, and the `#` starts at a whitespace/line boundary so headings and + /// mid-word `#` don't match), return the tag's full range and text + /// (including the leading `#`). Used to drive `#tag` autocomplete. + func tagContext(at loc: Int, in text: NSString, codeTokens: [MarkdownToken]) -> (range: NSRange, text: String)? { + guard loc >= 0, loc <= text.length else { return nil } + + // Scan left over tag-body characters from the caret. + var bodyStart = loc + while bodyStart > 0, isTagBodyCharacter(text.character(at: bodyStart - 1)) { + bodyStart -= 1 + } + // The character just before the body run must be the `#`. + let hash = bodyStart - 1 + guard hash >= 0, text.character(at: hash) == 0x23 /* # */ else { return nil } + // Require at least one body character (so bare `#`, `## `, `# ` don't match). + guard bodyStart < text.length, isTagBodyCharacter(text.character(at: bodyStart)) else { return nil } + // The `#` must start at a line/whitespace boundary (not `word#tag`). + if hash > 0 { + let before = text.character(at: hash - 1) + let isBoundary = before == 0x20 || before == 0x09 || before == 0x0A || before == 0x0D + guard isBoundary else { return nil } + } + + // Extend right over the rest of the tag body. + var end = loc + while end < text.length, isTagBodyCharacter(text.character(at: end)) { + end += 1 + } + let range = NSRange(location: hash, length: end - hash) + guard !MarkdownDetection.isInsideCodeBlock(range: range, codeTokens: codeTokens) else { return nil } + return (range, text.substring(with: range)) + } + + /// Characters allowed in a tag body: letters, digits, and `/ - _`. + private func isTagBodyCharacter(_ c: unichar) -> Bool { + if c == 0x2F /* / */ || c == 0x2D /* - */ || c == 0x5F /* _ */ { return true } + guard let scalar = Unicode.Scalar(c) else { return false } + return CharacterSet.alphanumerics.contains(scalar) + } + // MARK: - Image Embed Activation func filterImageEmbedActiveTokens(parsed: ParsedDocument, text: NSString, selectionLocation: Int) { diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift index 0246f4ec..85ea4bb1 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+Restyling.swift @@ -270,13 +270,21 @@ extension NativeTextViewCoordinator { return } - // Image embeds and node links share one path: insert DISPLAY form `![[Name]]` / `[[Name]]` - // with the opaque suffix on the `.wikiLinkID` side-channel (displayFragmentAndID handles `!`). - let replacementInfo = WikiLinkService.displayFragmentAndID(from: request.storageFragment) - let replacementDisplay = replacementInfo.display - let linkID = replacementInfo.id + let replacementDisplay: String + let linkID: String? + if request.isLiteralMode { + // Literal insertion (e.g. a `#tag`): insert the fragment verbatim. + replacementDisplay = request.storageFragment + linkID = nil + } else { + // Image embeds and node links share one path: insert DISPLAY form `![[Name]]` / `[[Name]]` + // with the opaque suffix on the `.wikiLinkID` side-channel (displayFragmentAndID handles `!`). + let replacementInfo = WikiLinkService.displayFragmentAndID(from: request.storageFragment) + replacementDisplay = replacementInfo.display + linkID = replacementInfo.id + } - let undoActionName = request.isImageEmbedMode ? "Insert Image Embed" : "Insert Link" + let undoActionName = request.isLiteralMode ? "Insert Tag" : "Insert Link" textView.breakUndoCoalescing() isProgrammaticEdit = true @@ -302,10 +310,12 @@ extension NativeTextViewCoordinator { textView.undoManager?.setActionName(undoActionName) textView.breakUndoCoalescing() - let caretRange = WikiLinkService.caretRangeAfterReplacing( - displayRange: range, - with: request.storageFragment - ) + let caretRange = request.isLiteralMode + ? NSRange(location: range.location + (replacementDisplay as NSString).length, length: 0) + : WikiLinkService.caretRangeAfterReplacing( + displayRange: range, + with: request.storageFragment + ) let documentLength = (textView.string as NSString).length let clampedCaret = NSRange(location: min(max(caretRange.location, 0), documentLength), length: 0) diff --git a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index 2dbae565..c0db959a 100644 --- a/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -429,6 +429,20 @@ extension NativeTextViewCoordinator { self.onCaretRectChange?(previewRect) } } + } else if isTyping, + let tag = tagContext(at: selLocation, in: nsString, codeTokens: codeTokens), + let tagRect = tv.viewRect(forCharacterRange: tag.range, using: layoutBridge) { + // Not inside a bracketed token, but the caret is in a `#tag` — offer + // tag autocomplete anchored at the tag. + let selection = WikiLinkSelection( + displayRange: tag.range, + storageRange: nil, + placeholder: tag.text + ) + inlineSelectionState = InlineSelectionState(kind: .tag, selection: selection) + DispatchQueue.main.async { + self.onCaretRectChange?(tagRect) + } } DispatchQueue.main.async { diff --git a/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift b/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift index 3489b229..de7c9929 100644 --- a/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift +++ b/Sources/MarkdownEngine/TextView/NativeTextViewSelectionTypes.swift @@ -34,6 +34,9 @@ public enum InlineSelectionKind: Sendable { case wikiLink /// A `![[Name]]` embedded-image reference. case imageEmbed + /// A `#tag` the caret is typing/inside. `selection.placeholder` is the tag + /// text including the leading `#`. + case tag } /// Snapshot of the inline token the caret is inside, delivered through @@ -78,18 +81,24 @@ public struct InlineReplacementRequest: Sendable { /// `true` when the fragment is a `![[…]]` image embed and the engine /// should treat it as a standalone block. public let isImageEmbedMode: Bool + /// `true` when the fragment is plain literal text (e.g. a `#tag`) that the + /// engine should insert verbatim, placing the caret at its end — no + /// wiki-link `[[Name|id]]` parsing. + public let isLiteralMode: Bool public init( id: UUID = UUID(), documentId: String, selection: WikiLinkSelection, storageFragment: String, - isImageEmbedMode: Bool + isImageEmbedMode: Bool, + isLiteralMode: Bool = false ) { self.id = id self.documentId = documentId self.selection = selection self.storageFragment = storageFragment self.isImageEmbedMode = isImageEmbedMode + self.isLiteralMode = isLiteralMode } }