diff --git a/frontend/src/components/shared/editor-inline-scan.ts b/frontend/src/components/shared/editor-inline-scan.ts index 878b9af2..5cacceb7 100644 --- a/frontend/src/components/shared/editor-inline-scan.ts +++ b/frontend/src/components/shared/editor-inline-scan.ts @@ -2,16 +2,16 @@ import type { Node as PMNode } from '@tiptap/pm/model'; export interface InlineMatch { from: number; - text: string; to: number; } // Find `regex` matches in each textblock's inline text, mapped to document positions. Unlike a per-text-node // scan, this reunites a token split across text nodes by a mark (e.g. a user styles one brace of `{{.Var}}`, // so ProseMirror splits it) — the brace and the rest sit in one block string again. ProseMirror positions are -// one per UTF-16 unit and mark-independent, so offset `i` in the block string maps to `blockStart + i`; a -// token never spans a non-text inline node (image/hard-break), so `to = from + length` holds. `regex` MUST be -// global (`/g`). Scanning per textblock — NOT over `doc.textContent` — keeps positions aligned across blocks. +// one per UTF-16 unit and mark-independent; `from`/`to` are read from the per-character position map (NOT +// `from + length`) so a token that also contains a non-text inline node — a hard break from Shift+Enter — +// still spans the right range. `regex` MUST be global (`/g`). Scanning per textblock — NOT over +// `doc.textContent` — keeps positions aligned across blocks. export const collectInlineMatches = (doc: PMNode, regex: RegExp): InlineMatch[] => { const matches: InlineMatch[] = []; @@ -36,10 +36,12 @@ export const collectInlineMatches = (doc: PMNode, regex: RegExp): InlineMatch[] }); for (const match of text.matchAll(regex)) { - const from = positions[match.index ?? 0]; + const start = match.index ?? 0; + const from = positions[start]; + const last = positions[start + match[0].length - 1]; - if (from !== undefined) { - matches.push({ from, text: match[0], to: from + match[0].length }); + if (from !== undefined && last !== undefined) { + matches.push({ from, to: last + 1 }); } } diff --git a/frontend/src/components/shared/markdown-editor-extensions.test.ts b/frontend/src/components/shared/markdown-editor-extensions.test.ts index 5f27997a..e9937bc5 100644 --- a/frontend/src/components/shared/markdown-editor-extensions.test.ts +++ b/frontend/src/components/shared/markdown-editor-extensions.test.ts @@ -173,4 +173,19 @@ describe('findVariableOccurrences — doc spans for the Available-variables cycl expect(doc.textBetween(hit.from, hit.to)).toBe('{{.Foo}}'); } }); + + it('spans the full token when a hard break sits inside it (non-text-node off-by-N)', () => { + const doc = docOf('{{.Foo\\\n}}'); + + // sanity — the hard break really is inside the token (text, hardBreak, text) + expect(doc.firstChild?.childCount ?? 0).toBeGreaterThan(1); + + const foo = findVariableOccurrences(doc, 'Foo'); + + expect(foo).toHaveLength(1); + + for (const hit of foo) { + expect(doc.textBetween(hit.from, hit.to)).toBe('{{.Foo}}'); + } + }); });