components/draft-compare/draft-compare.ts
161 lines and 26 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| readView | 29 to 36 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| writeView | 38 to 44 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| currentView | 47 to 50 | 3 | 25% | WatchCRAP 6.8 | No data: mutation testing has not run on this repo yet |
| setView | 53 to 56 | 2 | 25% | OkCRAP 3.7 | No data: mutation testing has not run on this repo yet |
| isTextEntry | 58 to 64 | 10 | 29% | CriticalCRAP 46.4 | No data: mutation testing has not run on this repo yet |
| attach | 66 to 157 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| onView | 80 to 83 | 3 | 0% | WatchCRAP 12 | No data: mutation testing has not run on this repo yet |
| <anonymous:85> | 85 to 85 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| target | 87 to 90 | 2 | 0% | WatchCRAP 6 | No data: mutation testing has not run on this repo yet |
| goTo | 92 to 108 | 10 | 0% | CriticalCRAP 110 | No data: mutation testing has not run on this repo yet |
| onClick | 110 to 122 | 7 | 0% | CriticalCRAP 56 | No data: mutation testing has not run on this repo yet |
| <anonymous:124> | 124 to 124 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| onKey | 128 to 137 | 13 | 0% | CriticalCRAP 182 | No data: mutation testing has not run on this repo yet |
| <anonymous:139> | 139 to 139 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| gaps | 143 to 143 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| syncExpand | 144 to 151 | 4 | 0% | WatchCRAP 20 | No data: mutation testing has not run on this repo yet |
| <anonymous:148> | 148 to 148 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:154> | 154 to 154 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:156> | 156 to 156 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:156> | 156 to 156 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| toggleAll | 160 to 164 | 2 | 25% | OkCRAP 3.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:162> | 162 to 162 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| enhance | 168 to 181 | 4 | 11% | WatchCRAP 15.2 | No data: mutation testing has not run on this repo yet |
| <anonymous:175> | 175 to 178 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:180> | 180 to 180 | 1 | 0% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:180> | 180 to 180 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports components/draft-compare/draft-compare-diff.ts, components/draft-compare/draft-compare-patch.ts, components/draft-compare/draft-compare-render.ts, components/row-list/row-list.ts. Imported by components/authorship/authorship.ts, components/draft-compare/draft-compare.react.tsx, components/draft-compare/examples.tsx.
Source
// Draft compare: two versions of a text, compared by sentence and then by word.
//
// Three parts, all plain TypeScript with no framework and nothing that touches the page at
// import time, so a server (or node's test runner) can use the first two:
// 1. the diff: diffWords, diffSentences, splitSentences, analyse (draft-compare-diff.ts)
// 2. the renderer: renderCompare(before, after, options) returns the HTML string
// (draft-compare-render.ts; renderPatch, for a git patch, in draft-compare-patch.ts)
// 3. the behaviour: enhance(root) wires the jump keys, the view memory and "expand all" (here)
//
// The HTML holds every view (side by side, inline, changes only) so a reader without script
// still gets all of it; the radios in the bar choose which one shows, in CSS.
import { singleKeysOff } from "../row-list/row-list.ts";
import { type CompareView, STORAGE_KEY, VIEWS } from "./draft-compare-render.ts";
export { tokenize, countWords, commonSubsequence, diffWords, splitSentences, diffSentences, analyse, countsText } from "./draft-compare-diff.ts";
export type { Op, WordPart, DiffOptions, Sentence, Entry, SentenceOptions, Counts, Analysis } from "./draft-compare-diff.ts";
export { VIEWS, STORAGE_KEY, esc, renderCompare } from "./draft-compare-render.ts";
export type { CompareView, CompareSide, RunKind, RunSpan, CompareLabels, CompareOptions } from "./draft-compare-render.ts";
export { parsePatch, patchCounts, patchCountsText, revisionPair, renderPatch } from "./draft-compare-patch.ts";
export type { PatchOp, PatchLine, PatchHunk, PatchFile, PatchOptions, PatchCounts } from "./draft-compare-patch.ts";
// ---------------------------------------------------------------------------------------
// The behaviour
// ---------------------------------------------------------------------------------------
const READY = "data-cap-ready";
function readView(): CompareView | null {
try {
const v = localStorage.getItem(STORAGE_KEY);
return v && (VIEWS as readonly string[]).includes(v) ? (v as CompareView) : null;
} catch {
return null;
}
}
function writeView(v: CompareView): void {
try {
localStorage.setItem(STORAGE_KEY, v);
} catch {
// Private windows and blocked storage: the choice simply is not remembered.
}
}
// The view the radios show now.
export function currentView(root: Element): CompareView {
const v = root.querySelector<HTMLInputElement>(".cap-compare-bar input[type='radio']:checked")?.value;
return v && (VIEWS as readonly string[]).includes(v) ? (v as CompareView) : "side";
}
// Chooses a view (the radio the CSS reads). Fires no event.
export function setView(root: Element, view: CompareView): void {
const radio = root.querySelector<HTMLInputElement>(`.cap-compare-bar input[type='radio'][value='${view}']`);
if (radio) radio.checked = true;
}
const isTextEntry = (e: KeyboardEvent): boolean => {
if (e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return true;
const t = e.target as HTMLElement | null;
if (!t) return false;
if (t.isContentEditable || /^(TEXTAREA|SELECT)$/.test(t.tagName) || t.closest("[role='combobox'], [role='textbox']")) return true;
return t.tagName === "INPUT" && !/^(radio|checkbox|button)$/.test((t as HTMLInputElement).type);
};
function attach(root: HTMLElement): () => void {
const live = root.querySelector<HTMLElement>("[data-cap-part='live']");
const total = root.querySelectorAll<HTMLElement>("[data-jump]").length;
let current = 0;
const undo: Array<() => void> = [];
for (const b of root.querySelectorAll<HTMLElement>("[data-cap-part='prev'], [data-cap-part='next']")) b.hidden = total === 0;
// The remembered view.
const remember = root.dataset.remember !== "off";
if (remember) {
const stored = readView();
if (stored) setView(root, stored);
}
const onView = (e: Event) => {
const t = e.target as HTMLInputElement;
if (remember && t.matches("input[type='radio']")) writeView(t.value as CompareView);
};
root.querySelector(".cap-compare-bar")?.addEventListener("change", onView);
undo.push(() => root.querySelector(".cap-compare-bar")?.removeEventListener("change", onView));
const target = (n: number): HTMLElement | null => {
const view = root.querySelector<HTMLElement>(`.cap-compare-view[data-view='${currentView(root)}']`);
return view?.querySelector<HTMLElement>(`[data-change='${n}']`) ?? null;
};
const goTo = (n: number) => {
if (total === 0) return;
current = ((((n - 1) % total) + total) % total) + 1;
for (const el of root.querySelectorAll<HTMLElement>("[data-change]")) {
if (el.dataset.change === String(current)) el.setAttribute("data-current", "");
else el.removeAttribute("data-current");
}
for (const a of root.querySelectorAll<HTMLElement>("[data-jump]")) {
if (a.dataset.jump === String(current)) a.setAttribute("aria-current", "true");
else a.removeAttribute("aria-current");
}
const el = target(current);
if (!el) return;
el.focus();
el.scrollIntoView({ block: "center" });
if (live) live.textContent = `Change ${current} of ${total}: ${el.dataset.added ?? 0} words added, ${el.dataset.removed ?? 0} removed.`;
};
const onClick = (e: Event) => {
const t = e.target as HTMLElement;
const link = t.closest<HTMLElement>("[data-jump]");
if (link && root.contains(link)) {
e.preventDefault();
goTo(Number(link.dataset.jump));
return;
}
const part = t.closest<HTMLElement>("[data-cap-part]")?.dataset.capPart;
if (part === "next") goTo(current + 1);
else if (part === "prev") goTo(current === 0 ? total : current - 1);
else if (part === "expand") toggleAll(root);
};
root.addEventListener("click", onClick);
undo.push(() => root.removeEventListener("click", onClick));
// n and p: single keys, so they honour the page's switch for them, never act while typing,
// and act only when focus is inside the compare or it is the page's primary one.
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || (e.key !== "n" && e.key !== "p")) return;
if (isTextEntry(e) || singleKeysOff() || total === 0) return;
const active = document.activeElement;
const owns = (active && root.contains(active)) || (root.hasAttribute("data-cap-primary") && !active?.closest("dialog[open], [role='dialog'], [role='menu'], [role='listbox']"));
if (!owns) return;
e.preventDefault();
if (e.key === "n") goTo(current + 1);
else goTo(current === 0 ? total : current - 1);
};
document.addEventListener("keydown", onKey);
undo.push(() => document.removeEventListener("keydown", onKey));
// "Expand all" appears when something is folded, and says which way it will go.
const expand = root.querySelector<HTMLButtonElement>("[data-cap-part='expand']");
const gaps = () => [...root.querySelectorAll<HTMLDetailsElement>("details[data-cap-part='gap']")];
const syncExpand = () => {
if (!expand) return;
const all = gaps();
expand.hidden = all.length === 0;
const open = all.length > 0 && all.every((d) => d.open);
expand.setAttribute("aria-pressed", String(open));
expand.textContent = open ? "Collapse unchanged" : "Expand all unchanged";
};
syncExpand();
root.addEventListener("toggle", syncExpand, true);
undo.push(() => root.removeEventListener("toggle", syncExpand, true));
return () => undo.forEach((f) => f());
}
// Opens every folded passage, or closes them all when every one is open.
export function toggleAll(root: Element): void {
const gaps = [...root.querySelectorAll<HTMLDetailsElement>("details[data-cap-part='gap']")];
const open = !gaps.every((d) => d.open);
for (const d of gaps) d.open = open;
}
// Attaches to every [data-cap="draft-compare"] under root (or root itself) not yet attached.
// Returns a function that detaches them all.
export function enhance(root: ParentNode = document): () => void {
const found = [...root.querySelectorAll<HTMLElement>("[data-cap='draft-compare']:not([data-cap-ready])")];
if (root instanceof HTMLElement && root.matches("[data-cap='draft-compare']:not([data-cap-ready])")) found.unshift(root);
const undo: Array<() => void> = [];
for (const el of found) {
el.setAttribute(READY, "");
const off = attach(el);
undo.push(() => {
off();
el.removeAttribute(READY);
});
}
return () => undo.forEach((f) => f());
}