F7: rendered track-changes markdown preview (#21) #23
@@ -69,3 +69,55 @@ export function splitBlocks(text: string): Block[] {
|
||||
flushProse();
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export type BlockOp =
|
||||
| { kind: "unchanged"; block: Block }
|
||||
| { kind: "added"; block: Block }
|
||||
| { kind: "removed"; block: Block }
|
||||
| { kind: "changed"; block: Block; before: Block; atomic: boolean };
|
||||
|
||||
/**
|
||||
* Diff two block sequences. Matching is by normalized key (jsdiff `diffArrays`);
|
||||
* adjacent removed-then-added runs are paired element-wise into `changed` ops, the
|
||||
* surplus staying `removed` / `added`. A `changed` op is ATOMIC (INV-23) when
|
||||
* either side is a code/mermaid fence — rendered whole, never word-refined.
|
||||
*/
|
||||
export function diffBlocks(baselineText: string, currentText: string): BlockOp[] {
|
||||
const before = splitBlocks(baselineText);
|
||||
const after = splitBlocks(currentText);
|
||||
const changes = diffArrays(
|
||||
before.map((b) => b.key),
|
||||
after.map((b) => b.key),
|
||||
);
|
||||
|
||||
const ops: BlockOp[] = [];
|
||||
let bi = 0; // index into `before`
|
||||
let ci = 0; // index into `after`
|
||||
for (let n = 0; n < changes.length; n++) {
|
||||
const ch = changes[n];
|
||||
const count = ch.count ?? ch.value.length;
|
||||
if (!ch.added && !ch.removed) {
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "unchanged", block: after[ci++] });
|
||||
bi += count;
|
||||
continue;
|
||||
}
|
||||
if (ch.removed) {
|
||||
const next = changes[n + 1];
|
||||
const addCount = next?.added ? (next.count ?? next.value.length) : 0;
|
||||
const paired = Math.min(count, addCount);
|
||||
for (let k = 0; k < paired; k++) {
|
||||
const beforeBlk = before[bi++];
|
||||
const afterBlk = after[ci++];
|
||||
const atomic = beforeBlk.type !== "prose" || afterBlk.type !== "prose";
|
||||
ops.push({ kind: "changed", block: afterBlk, before: beforeBlk, atomic });
|
||||
}
|
||||
for (let k = paired; k < count; k++) ops.push({ kind: "removed", block: before[bi++] });
|
||||
for (let k = paired; k < addCount; k++) ops.push({ kind: "added", block: after[ci++] });
|
||||
if (next?.added) n++; // consumed the paired added run
|
||||
continue;
|
||||
}
|
||||
// a lone added run (no preceding removed)
|
||||
for (let k = 0; k < count; k++) ops.push({ kind: "added", block: after[ci++] });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { splitBlocks } from "../src/trackChangesModel";
|
||||
import { splitBlocks, diffBlocks } from "../src/trackChangesModel";
|
||||
|
||||
describe("splitBlocks", () => {
|
||||
it("splits prose paragraphs on blank lines, dropping empties", () => {
|
||||
@@ -27,3 +27,52 @@ describe("splitBlocks", () => {
|
||||
expect(blocks[0].key).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffBlocks", () => {
|
||||
const kinds = (base: string, cur: string) => diffBlocks(base, cur).map((o) => o.kind);
|
||||
|
||||
it("unchanged doc → all unchanged", () => {
|
||||
const doc = "Alpha.\n\nBravo.\n";
|
||||
expect(kinds(doc, doc)).toEqual(["unchanged", "unchanged"]);
|
||||
});
|
||||
|
||||
it("a pure addition → an added op", () => {
|
||||
const ops = diffBlocks("Alpha.\n", "Alpha.\n\nBravo.\n");
|
||||
expect(ops.map((o) => o.kind)).toEqual(["unchanged", "added"]);
|
||||
});
|
||||
|
||||
it("a pure deletion → a removed op", () => {
|
||||
const ops = diffBlocks("Alpha.\n\nBravo.\n", "Alpha.\n");
|
||||
expect(ops.map((o) => o.kind)).toEqual(["unchanged", "removed"]);
|
||||
});
|
||||
|
||||
it("a prose modification → a non-atomic changed op", () => {
|
||||
const ops = diffBlocks("The quick fox.\n", "The slow fox.\n");
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].kind).toBe("changed");
|
||||
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(false);
|
||||
});
|
||||
|
||||
it("a code-fence change → an ATOMIC changed op (INV-23)", () => {
|
||||
const base = "```ts\nconst a = 1;\n```\n";
|
||||
const cur = "```ts\nconst a = 2;\n```\n";
|
||||
const ops = diffBlocks(base, cur);
|
||||
expect(ops).toHaveLength(1);
|
||||
expect(ops[0].kind).toBe("changed");
|
||||
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
|
||||
});
|
||||
|
||||
it("a mermaid-fence change → an ATOMIC changed op", () => {
|
||||
const base = "```mermaid\nflowchart LR\n a-->b\n```\n";
|
||||
const cur = "```mermaid\nflowchart LR\n a-->c\n```\n";
|
||||
const ops = diffBlocks(base, cur);
|
||||
expect(ops[0].kind).toBe("changed");
|
||||
expect(ops[0].kind === "changed" && ops[0].atomic).toBe(true);
|
||||
});
|
||||
|
||||
it("a reorder → keeps a matched block unchanged", () => {
|
||||
const ops = diffBlocks("One.\n\nTwo.\n", "Two.\n\nOne.\n");
|
||||
const k = ops.map((o) => o.kind);
|
||||
expect(k).toContain("unchanged");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user