refactor(app): implement canvas-based diff rendering

This commit is contained in:
Mohamed Boudra
2026-08-17 21:14:37 +02:00
parent 75adf53893
commit d03cf14703
66 changed files with 6147 additions and 3210 deletions
@@ -0,0 +1,205 @@
import { selectionRectangles } from "./hit-testing";
import { fragmentWidthForRange, visibleRowRange } from "./model";
import { codeLineNumberTone, codeTextColor } from "./palette";
import { reviewBackgroundPaint, reviewDividerHeight, reviewGapTop } from "./review-paint";
import type {
DiffCell,
DiffDocumentModel,
DiffLineRow,
DiffPalette,
DiffSelection,
DiffTypography,
TextMeasurer,
} from "./types";
const CODE_LEFT_PADDING = 8;
export interface PaintWebViewportInput {
context: CanvasRenderingContext2D;
model: DiffDocumentModel;
palette: DiffPalette;
typography: DiffTypography;
measureText: TextMeasurer;
scrollTop: number;
viewportWidth: number;
viewportHeight: number;
horizontalOffsets: ReadonlyMap<string, number>;
selection: DiffSelection | null;
devicePixelRatio: number;
}
export function paintWebViewport(input: PaintWebViewportInput): void {
const { context } = input;
const scale = input.devicePixelRatio;
context.setTransform(scale, 0, 0, scale, 0, 0);
context.clearRect(0, 0, input.viewportWidth, input.viewportHeight);
context.fillStyle = input.palette.surface;
context.fillRect(0, 0, input.viewportWidth, input.viewportHeight);
context.font = `${input.typography.size}px ${input.typography.family}`;
context.textBaseline = "alphabetic";
const range = visibleRowRange(input.model.rows, input.scrollTop, input.viewportHeight);
for (let index = range.start; index < range.end; index += 1) {
const row = input.model.rows[index];
if (!row) continue;
const y = row.top - input.scrollTop;
if (row.kind === "status") {
context.fillStyle = input.palette.foregroundMuted;
context.fillText(row.label, 12, y + input.model.lineHeight + 6);
continue;
}
paintLine({
...input,
row,
y,
horizontalOffset: input.horizontalOffsets.get(row.path) ?? 0,
});
}
if (input.selection) paintSelection(input, input.selection);
}
function paintLine(
input: PaintWebViewportInput & {
row: DiffLineRow;
y: number;
horizontalOffset: number;
},
): void {
const columnWidth = input.viewportWidth / input.row.cells.length;
const file = input.model.files[input.row.fileIndex];
if (!file) return;
input.row.cells.forEach((cell, cellIndex) => {
const x = cellIndex * columnWidth;
input.context.fillStyle = cellBackground(cell, input.palette);
input.context.fillRect(x, input.y, columnWidth, input.row.height);
if (input.row.reviewHeight > 0) {
input.context.fillStyle = reviewBackgroundPaint(input.palette.surface);
input.context.fillRect(
x,
reviewGapTop(input.y, input.row.height, input.row.reviewHeight),
columnWidth,
input.row.reviewHeight,
);
}
if (!cell) return;
input.context.fillStyle = input.palette.border;
input.context.fillRect(x + file.gutterWidth, input.y, 1, reviewDividerHeight(input.row.height));
if (cell.lineNumber !== null) {
const label = String(cell.lineNumber);
input.context.fillStyle = lineNumberColor(cell, input.palette);
input.context.fillText(
label,
x + file.gutterWidth - 7 - input.measureText.measure(label),
input.y + input.model.lineHeight * 0.78,
);
}
input.context.save();
input.context.beginPath();
input.context.rect(
x + file.gutterWidth + CODE_LEFT_PADDING,
input.y,
columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
input.row.height - input.row.reviewHeight,
);
input.context.clip();
paintCellText({ ...input, cell, x: x + file.gutterWidth + CODE_LEFT_PADDING });
input.context.restore();
});
}
function paintCellText(
input: PaintWebViewportInput & {
row: DiffLineRow;
cell: DiffCell;
x: number;
y: number;
horizontalOffset: number;
},
): void {
const offset = input.model.wrapLines ? 0 : input.horizontalOffset;
for (const fragment of input.cell.fragments) {
const baseline = input.y + fragment.baseline;
const textX = input.x - offset;
for (const band of fragmentColorBands(input.cell, fragment, input.palette)) {
input.context.save();
input.context.beginPath();
input.context.rect(
textX + band.x,
input.y + fragment.top,
band.width,
input.model.lineHeight,
);
input.context.clip();
input.context.fillStyle = band.color;
// Keep the complete shaped string and origin used during measurement. Clipping changes
// syntax color without breaking ligatures, kerning, or contextual forms into new runs.
input.context.fillText(fragment.text, textX, baseline);
input.context.restore();
}
}
}
function fragmentColorBands(
cell: DiffCell,
fragment: DiffCell["fragments"][number],
palette: DiffPalette,
): Array<{ x: number; width: number; color: string }> {
const ranges: Array<{ start: number; end: number; color: string }> = [];
for (const grapheme of fragment.graphemes) {
const syntaxRun = cell.tokens.find(
(run) => run.start <= grapheme.start && grapheme.start < run.end,
);
const color = syntaxRun?.color ?? cellForeground(cell, palette);
const previous = ranges.at(-1);
if (previous?.color === color) previous.end = grapheme.end;
else ranges.push({ start: grapheme.start, end: grapheme.end, color });
}
if (ranges.length === 0) {
return [{ x: 0, width: Math.max(1, fragment.width), color: cellForeground(cell, palette) }];
}
return ranges.map((range) => ({
x: fragmentWidthForRange(fragment, fragment.start, range.start),
width: fragmentWidthForRange(fragment, range.start, range.end),
color: range.color,
}));
}
function paintSelection(input: PaintWebViewportInput, selection: DiffSelection): void {
input.context.save();
input.context.globalAlpha = 0.45;
input.context.fillStyle = input.palette.selection;
for (const rectangle of selectionRectangles({
model: input.model,
selection,
})) {
const file = input.model.files[rectangle.fileIndex];
if (!file) continue;
const x =
rectangle.x - (input.model.wrapLines ? 0 : (input.horizontalOffsets.get(file.path) ?? 0));
const y = rectangle.y - input.scrollTop;
input.context.save();
input.context.beginPath();
input.context.rect(rectangle.clipX, y, rectangle.clipWidth, rectangle.height);
input.context.clip();
input.context.fillRect(x, y, rectangle.width, rectangle.height);
input.context.restore();
}
input.context.restore();
}
function cellBackground(cell: DiffCell | null, palette: DiffPalette): string {
if (!cell) return palette.emptyBackground;
if (cell.type === "add") return palette.additionBackground;
if (cell.type === "remove") return palette.deletionBackground;
if (cell.type === "header") return palette.headerSurface;
return palette.surface;
}
function cellForeground(cell: DiffCell, palette: DiffPalette): string {
return codeTextColor(cell, palette);
}
function lineNumberColor(cell: DiffCell, palette: DiffPalette): string {
return palette[codeLineNumberTone(cell)];
}