refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -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)];
|
||||
}
|
||||
Reference in New Issue
Block a user