feat(f11): SLICE-2 — block-level data-src emission (#43, INV-36)
The pure render layer now emits data-src-start/data-src-end (source char offsets from BlockWithRange) on every LIVE-source rendered block, in BOTH modes — the contract the webview's selection→source mapping (SLICE-4) walks the DOM for. Per spec §6.1 INV-36 / §7.2 SLICE-2. - trackChangesModel: shared `srcAttr(blk)` helper; threaded through renderOp + renderReviewOp + the renderReview loop (removed blocks → "" / no data-src, as they have no live source). renderPlain switches from a single whole-document markdown pass to per-block `<div data-src-start/end>` wrappers — bare divs (no cw- class) so the off/clean preview stays visually clean while becoming a selection→source surface. Pure, vscode-free, deterministic (extends INV-22). - unit (test/trackChangesModel.test.ts): data-src offsets equal splitBlocksWithRanges in both modes; removed + proposal blocks carry none; off-mode stays cw--free; determinism. 200 unit + 47 host E2E green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+34
-11
@@ -247,7 +247,17 @@ function chip(message: string): string {
|
|||||||
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
return `<div class="cw-error">Could not render this block: ${md.utils.escapeHtml(message)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 => {
|
const safe = (src: string): string => {
|
||||||
try {
|
try {
|
||||||
return render(src);
|
return render(src);
|
||||||
@@ -293,7 +303,7 @@ function renderOp(op: BlockOp, render: (src: string) => string): string {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return `<div class="cw-blk ${cls}">${badge}${inner}</div>`;
|
return `<div class="cw-blk ${cls}"${src}>${badge}${inner}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuthorKind = "claude" | "human";
|
export type AuthorKind = "claude" | "human";
|
||||||
@@ -367,14 +377,25 @@ export function colorByAuthor(
|
|||||||
return sentinelsToSpans(render(injected));
|
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 `<div data-src-start/end>` 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 {
|
export function renderPlain(currentText: string, opts: RenderOptions = {}): string {
|
||||||
const render = opts.render ?? defaultRender;
|
const render = opts.render ?? defaultRender;
|
||||||
try {
|
const safe = (src: string): string => {
|
||||||
return render(currentText);
|
try {
|
||||||
} catch (err) {
|
return render(src);
|
||||||
return chip(err instanceof Error ? err.message : String(err));
|
} 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) => `<div${srcAttr(b)}>${safe(b.raw)}</div>`).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProposalView {
|
export interface ProposalView {
|
||||||
@@ -411,11 +432,13 @@ function renderReviewOp(
|
|||||||
op: BlockOp,
|
op: BlockOp,
|
||||||
render: (src: string) => string,
|
render: (src: string) => string,
|
||||||
colored: (raw: string) => string,
|
colored: (raw: string) => string,
|
||||||
|
src: string,
|
||||||
): string {
|
): string {
|
||||||
// removed blocks and any changed block (atomic fences diffed whole; non-atomic prose
|
// 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).
|
// word-merged) render via renderOp — no author sentinels (deletions neutral, spec §6.7).
|
||||||
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render);
|
// `src` is "" for a removed block (no live source — INV-36).
|
||||||
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}">${colored(op.block.raw)}</div>`;
|
if (op.kind === "removed" || op.kind === "changed") return renderOp(op, render, src);
|
||||||
|
return `<div class="cw-blk ${op.kind === "added" ? "cw-added" : "cw-unchanged"}"${src}>${colored(op.block.raw)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,7 +494,7 @@ export function renderReview(
|
|||||||
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
const blk = op.kind === "removed" ? undefined : ranges[ci++];
|
||||||
const colored = (raw: string): string =>
|
const colored = (raw: string): string =>
|
||||||
blk ? colorByAuthor(raw, blk.start, authorSpans, render) : render(raw);
|
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;
|
const here = blockIndex >= 0 ? byBlock.get(blockIndex) : undefined;
|
||||||
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
if (here) for (const p of here) bodyParts.push(proposalBlockHtml(p, render));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -319,3 +319,51 @@ describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
|
|||||||
expect(html).not.toContain("classDef cwAdded");
|
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("<div", propIdx), propIdx + 1);
|
||||||
|
expect(propTag).not.toContain("data-src-start");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("data-src emission is deterministic — same inputs → identical HTML (extends INV-22)", () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user