diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index 13989d9..bb44737 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -247,7 +247,17 @@ function chip(message: string): string { return `
Could not render this block: ${md.utils.escapeHtml(message)}
`; } -function renderOp(op: BlockOp, render: (src: string) => string): string { +/** + * F11 (INV-36): the source-range attributes a live block's wrapping element + * carries, so a preview selection can map back to a source markdown range. `""` + * when the block has no live source (a baseline-only deletion / a proposal + * block) — those are skipped by the selection mapper. Pure + deterministic. + */ +function srcAttr(blk: { start: number; end: number } | undefined): string { + return blk ? ` data-src-start="${blk.start}" data-src-end="${blk.end}"` : ""; +} + +function renderOp(op: BlockOp, render: (src: string) => string, src = ""): string { const safe = (src: string): string => { try { return render(src); @@ -293,7 +303,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string { } break; } - return `
${badge}${inner}
`; + return `
${badge}${inner}
`; } export type AuthorKind = "claude" | "human"; @@ -367,14 +377,25 @@ export function colorByAuthor( return sentinelsToSpans(render(injected)); } -/** Off-state body: the current buffer as plain markdown, no annotations (INV-33). */ +/** + * Off-state body: the current buffer as plain markdown, no annotations (INV-33). + * Each block is wrapped in a bare `
` so the off-mode + * preview is still a selection→source mapping surface (INV-36) while staying + * visually clean — no `cw-` annotation classes. A doc with no blocks (empty / + * whitespace-only) renders whole. Pure + deterministic. + */ export function renderPlain(currentText: string, opts: RenderOptions = {}): string { const render = opts.render ?? defaultRender; - try { - return render(currentText); - } catch (err) { - return chip(err instanceof Error ? err.message : String(err)); - } + const safe = (src: string): string => { + try { + return render(src); + } catch (err) { + return chip(err instanceof Error ? err.message : String(err)); + } + }; + const blocks = splitBlocksWithRanges(currentText); + if (blocks.length === 0) return safe(currentText); + return blocks.map((b) => `${safe(b.raw)}
`).join("\n"); } export interface ProposalView { @@ -411,11 +432,13 @@ function renderReviewOp( op: BlockOp, render: (src: string) => string, colored: (raw: string) => string, + src: string, ): string { // removed blocks and any changed block (atomic fences diffed whole; non-atomic prose // word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7). - if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render); - return `
${colored(op.block.raw)}
`; + // `src` is "" for a removed block (no live source — INV-36). + if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src); + return `
${colored(op.block.raw)}
`; } /** @@ -471,7 +494,7 @@ export function renderReview( const blk = op.kind === "removed" ? undefined : ranges[ci++]; const colored = (raw: string): string => blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw); - bodyParts.push(renderReviewOp(op, render, colored)); + bodyParts.push(renderReviewOp(op, render, colored, srcAttr(blk))); const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined; if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render)); } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 72c06e7..e995f56 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -319,3 +319,51 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => { expect(html).not.toContain("classDef cwAdded"); }); }); + +// F11 SLICE-2 (INV-36): the pure render layer emits data-src-start/data-src-end +// (source char offsets from BlockWithRange) on every LIVE-source rendered block, +// in BOTH modes. The webview's selection→source mapping walks the DOM to the +// nearest data-src ancestor; these offsets are the contract. +describe("F11 data-src emission (INV-36)", () => { + /** Pull every data-src-start/end pair from an HTML string, in document order. */ + const srcRanges = (html: string): Array<{ start: number; end: number }> => + Array.from(html.matchAll(/data-src-start="(\d+)" data-src-end="(\d+)"/g)).map((m) => ({ + start: Number(m[1]), + end: Number(m[2]), + })); + + test("renderPlain wraps every block with data-src offsets equal to splitBlocksWithRanges (and stays clean)", () => { + const doc = "# Title\n\nFirst para.\n\nSecond para.\n"; + const blocks = splitBlocksWithRanges(doc); + const html = renderPlain(doc); + expect(srcRanges(html)).toEqual(blocks.map((b) => ({ start: b.start, end: b.end }))); + // off-mode stays the clean preview: no annotation marks. + expect(html).not.toContain("cw-"); + // each range slices back to its block's raw source. + for (const b of blocks) expect(doc.slice(b.start, b.end)).toBe(b.raw); + }); + + test("renderReview emits data-src on each live block; removed + proposal blocks carry none (INV-36)", () => { + // baseline has an extra paragraph that is REMOVED in current; current adds one. + const baseline = "Keep this.\n\nDrop this.\n"; + const current = "Keep this.\n\nBrand new.\n"; + const proposals: ProposalView[] = [{ id: "p1", anchorStart: 0, anchorEnd: 4, replaced: "Keep", replacement: "Hold" }]; + const html = renderReview(baseline, current, [], proposals); + const liveBlocks = splitBlocksWithRanges(current); + // exactly one data-src per LIVE (current-side) block — removed/proposal blocks excluded. + expect(srcRanges(html)).toEqual(liveBlocks.map((b) => ({ start: b.start, end: b.end }))); + // the proposal block itself is not a live-source block. + const propIdx = html.indexOf('data-proposal-id="p1"'); + const propTag = html.slice(html.lastIndexOf(" { + const a = renderPlain("alpha\n\nbeta\n"); + const b = renderPlain("alpha\n\nbeta\n"); + expect(a).toBe(b); + const r1 = renderReview("x", "x\n\ny", [], []); + const r2 = renderReview("x", "x\n\ny", [], []); + expect(r1).toBe(r2); + }); +});