fix(editor): map token end so a hard break inside {{.Var}}/<tag> spans fully

collectInlineMatches derived `to = from + match.length`, which undershoots
when a non-text inline node (a hard break from Shift+Enter) sits inside a
{{...}} or <tag> token: the highlight decoration and the cycle/select then
land one char short (off by the node's size). Read `to` from the per-character
position map instead. View-only — getMarkdown() was already byte-identical.

The module comment asserted the opposite (false) invariant; corrected. Test
inserts a hard break inside a token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-06-30 12:58:04 +07:00
co-authored by Claude Opus 4.8
parent 69f86ae566
commit bb695df721
2 changed files with 24 additions and 7 deletions
@@ -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 });
}
}
@@ -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}}');
}
});
});