Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}