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 `
` 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);
+ });
+});