Files
Outpost/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Input/MarkdownInputHandler.swift
T
Puranjay Savar Mattas 6e1ffb96fd chore: vendor swift-markdown-engine as plain tracked source
Convert from git submodule to plain vendored copy. No push access to
upstream nodes-app/swift-markdown-engine meant our local fix commit
(05c1720) was stranded and unreachable from any remote on fresh
clones/CI. Vendoring as plain files folds it into normal repo history
instead.
2026-08-20 13:26:13 +01:00

131 lines
5.9 KiB
Swift

//
// MarkdownInputHandler.swift
// MarkdownEngine
//
// Created by Luca Chen on 18.02.26.
//
// Handles Markdown typing shortcuts, like continuing lists and keeping block
// LaTeX on its own line while you type.
import AppKit
enum MarkdownInputHandler {
/// `codeTokens` (codeBlock + inlineCode, from the keystroke's existing
/// parse) answers "is the caret in code?" without the O(doc) document
/// scan the handler otherwise runs on every space/Enter/Tab.
static func handleListInsertion(textView: NSTextView, affectedCharRange: NSRange, replacementString: String?, codeTokens: [MarkdownToken]? = nil) -> Bool {
let isInsideCodeBlock = codeTokens.map {
MarkdownDetection.isInsideCodeBlock(location: affectedCharRange.location, codeTokens: $0)
}
return MarkdownLists.handleInsertion(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, isInsideCodeBlock: isInsideCodeBlock)
}
// MARK: - Block LaTeX Auto-Wrap
private static func insertTextProgrammatically(_ textView: NSTextView, text: String, at range: NSRange, cursorAfter: Int) {
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = true
// Replaces a suppressed keystroke that never applied — reset its
// pending count so this edit registers as the cycle's single
// tracked edit and textDidChange keeps the trusted fast paths.
coord.pendingEditCount = 0
}
textView.insertText(text, replacementRange: range)
if let coord = textView.delegate as? NativeTextViewWrapper.Coordinator {
coord.isProgrammaticEdit = false
}
textView.setSelectedRange(NSRange(location: cursorAfter, length: 0))
}
/// Keeps block LaTeX ($$...$$) on its own line by inserting newlines; returns true if handled.
static func handleBlockLatexAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
blockLatexTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let blockLatexTokens {
resolvedTokens = blockLatexTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .blockLatex }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Ensures image embeds (![[...]]) stay on their own line by automatically inserting newlines.
static func handleImageEmbedAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
imageEmbedTokens: [MarkdownToken]? = nil
) -> Bool {
let resolvedTokens: [MarkdownToken]
if let imageEmbedTokens {
resolvedTokens = imageEmbedTokens
} else {
resolvedTokens = MarkdownTokenizer.parseTokensViaAST(
in: textView.string,
registry: (textView as? NativeTextView)?.configuration.extensionRegistry ?? .empty
).filter { $0.kind == .imageEmbed }
}
return handleBlockAutoWrap(textView: textView, affectedCharRange: affectedCharRange,
replacementString: replacementString, tokens: resolvedTokens)
}
/// Shared auto-wrap logic: ensures a block-level token stays on its own line.
private static func handleBlockAutoWrap(
textView: NSTextView,
affectedCharRange: NSRange,
replacementString: String?,
tokens: [MarkdownToken]
) -> Bool {
guard let replacement = replacementString,
!replacement.isEmpty,
replacement != "\n" else { return false }
let text = textView.string as NSString
let newlineChar = UInt16(("\n" as Character).asciiValue!)
for token in tokens {
let tokenEnd = NSMaxRange(token.range)
// Typing right after closing marker
if affectedCharRange.location == tokenEnd {
if tokenEnd < text.length && text.character(at: tokenEnd) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: tokenEnd + 1, length: 0),
cursorAfter: tokenEnd + 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: "\n" + replacement,
at: affectedCharRange,
cursorAfter: affectedCharRange.location + 1 + replacement.utf16.count)
}
return true
}
// Typing right before opening marker
if affectedCharRange.location == token.range.location {
if token.range.location > 0 && text.character(at: token.range.location - 1) == newlineChar {
insertTextProgrammatically(textView, text: replacement,
at: NSRange(location: token.range.location - 1, length: 0),
cursorAfter: token.range.location - 1 + replacement.utf16.count)
} else {
insertTextProgrammatically(textView, text: replacement + "\n",
at: affectedCharRange,
cursorAfter: affectedCharRange.location + replacement.utf16.count)
}
return true
}
}
return false
}
}