|
|
@@ -100,49 +100,65 @@ export function getPatternSession(state: EditorState): PatternSession | null {
|
|
|
}
|
|
|
const forceCloseMetaKey = "pattern-force-close"
|
|
|
|
|
|
-function findPattern(
|
|
|
+/**
|
|
|
+ * Extract text before cursor on the current line, respecting hard_break boundaries.
|
|
|
+ * Returns the line's text and the parentOffset at which the line started.
|
|
|
+ */
|
|
|
+function getTextBeforeCursor(
|
|
|
state: EditorState,
|
|
|
- prevSession: PatternSession | null,
|
|
|
-): PatternSession | null {
|
|
|
+): { textBefore: string; lineStartOffset: number } | null {
|
|
|
const { $head } = state.selection
|
|
|
const parentNode = $head.parent
|
|
|
- if (!parentNode || !parentNode.isTextblock) return null
|
|
|
+ if (!parentNode?.isTextblock) return null
|
|
|
|
|
|
- // Build layout-aware textBefore by iterating nodes
|
|
|
- // This respects hard_break boundaries instead of flattening via textContent
|
|
|
let textBefore = ""
|
|
|
let currentOffset = 0
|
|
|
+ let lineStartOffset = 0
|
|
|
const targetOffset = $head.parentOffset
|
|
|
|
|
|
for (let i = 0; i < parentNode.childCount; i++) {
|
|
|
const child = parentNode.child(i)
|
|
|
-
|
|
|
if (currentOffset >= targetOffset) break
|
|
|
|
|
|
if (child.type.name === "hard_break") {
|
|
|
- // Hard break resets the current line
|
|
|
textBefore = ""
|
|
|
currentOffset += child.nodeSize
|
|
|
+ lineStartOffset = currentOffset
|
|
|
} else if (child.isText && child.text) {
|
|
|
const remainingSpace = targetOffset - currentOffset
|
|
|
if (remainingSpace <= child.text.length) {
|
|
|
textBefore += child.text.slice(0, remainingSpace)
|
|
|
break
|
|
|
- } else {
|
|
|
- textBefore += child.text
|
|
|
- currentOffset += child.text.length
|
|
|
}
|
|
|
+ textBefore += child.text
|
|
|
+ currentOffset += child.text.length
|
|
|
} else {
|
|
|
currentOffset += child.nodeSize
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ return { textBefore, lineStartOffset }
|
|
|
+}
|
|
|
+
|
|
|
+function findPattern(
|
|
|
+ state: EditorState,
|
|
|
+ prevSession: PatternSession | null,
|
|
|
+): PatternSession | null {
|
|
|
+ const { $head } = state.selection
|
|
|
+ const parentNode = $head.parent
|
|
|
+ if (!parentNode?.isTextblock) return null
|
|
|
+
|
|
|
+ const info = getTextBeforeCursor(state)
|
|
|
+ if (!info) return null
|
|
|
+
|
|
|
+ const { textBefore, lineStartOffset } = info
|
|
|
+
|
|
|
for (const spec of PATTERN_SPECS) {
|
|
|
const match = matchPattern(textBefore, spec)
|
|
|
if (!match) continue
|
|
|
|
|
|
const { startIdx, trigger, query } = match
|
|
|
- const startPos = $head.start() + startIdx
|
|
|
+ const startPos = $head.start() + lineStartOffset + startIdx
|
|
|
if (isPatternCompleted(spec, query)) return null
|
|
|
|
|
|
// For delimiter-terminated patterns, check if the closing delimiter
|
|
|
@@ -203,6 +219,14 @@ function matchPattern(
|
|
|
if (idx === -1) return null
|
|
|
|
|
|
const afterTrigger = textBefore.slice(idx + start.length)
|
|
|
+
|
|
|
+ // For delimiter-terminated patterns ([[, ((), the query includes everything
|
|
|
+ // until the closing delimiter — spaces are allowed in page/block names.
|
|
|
+ // For boundary patterns (/, #), the query must be a single word.
|
|
|
+ if (spec.termination === "delimiter") {
|
|
|
+ return { startIdx: idx, trigger: start, query: afterTrigger }
|
|
|
+ }
|
|
|
+
|
|
|
const queryMatch = afterTrigger.match(/^[^\s\n]*/)
|
|
|
if (!queryMatch) return null
|
|
|
const query = queryMatch[0]
|
|
|
@@ -225,18 +249,17 @@ function getCurrentRange(
|
|
|
state: EditorState,
|
|
|
trigger: string,
|
|
|
): { from: number; to: number } | null {
|
|
|
- const { $head } = state.selection
|
|
|
+ const $head = state.selection.$head
|
|
|
if (!$head.parent.isTextblock) return null
|
|
|
|
|
|
- const text = $head.parent.textContent
|
|
|
- const offset = $head.parentOffset
|
|
|
- const textBefore = text.slice(0, offset)
|
|
|
+ const info = getTextBeforeCursor(state)
|
|
|
+ if (!info) return null
|
|
|
|
|
|
- const startIdx = textBefore.lastIndexOf(trigger)
|
|
|
+ const startIdx = info.textBefore.lastIndexOf(trigger)
|
|
|
if (startIdx === -1) return null
|
|
|
|
|
|
- const startPos = $head.start() + startIdx
|
|
|
- const query = textBefore.slice(startIdx + trigger.length)
|
|
|
+ const startPos = $head.start() + info.lineStartOffset + startIdx
|
|
|
+ const query = info.textBefore.slice(startIdx + trigger.length)
|
|
|
|
|
|
return { from: startPos, to: startPos + trigger.length + query.length }
|
|
|
}
|
|
|
@@ -320,29 +343,31 @@ class PatternView {
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- const { $head } = this.view.state.selection
|
|
|
+ const $head = this.view.state.selection.$head
|
|
|
if (!$head.parent.isTextblock) {
|
|
|
this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- const text = $head.parent.textContent
|
|
|
- const offset = $head.parentOffset
|
|
|
- const textBefore = text.slice(0, offset)
|
|
|
+ const info = getTextBeforeCursor(this.view.state)
|
|
|
+ if (!info) {
|
|
|
+ this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
|
|
|
+ return
|
|
|
+ }
|
|
|
|
|
|
- const startIdx = textBefore.lastIndexOf(session.trigger)
|
|
|
+ const startIdx = info.textBefore.lastIndexOf(session.trigger)
|
|
|
if (startIdx === -1) {
|
|
|
this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- const startPos = $head.start() + startIdx
|
|
|
+ const startPos = $head.start() + info.lineStartOffset + startIdx
|
|
|
if (startPos !== session.range.from) {
|
|
|
this.callbacks.onPatternClose({ id: session.id, reason: "cancelled" })
|
|
|
return
|
|
|
}
|
|
|
|
|
|
- const query = textBefore.slice(startIdx + session.trigger.length)
|
|
|
+ const query = info.textBefore.slice(startIdx + session.trigger.length)
|
|
|
|
|
|
let isComplete = false
|
|
|
if (spec.start === "[[") {
|