mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { fileAtDocumentOffset, fragmentWidthForRange } from "./model";
|
||||
import type {
|
||||
DiffCell,
|
||||
DiffCharacterPosition,
|
||||
DiffDocumentModel,
|
||||
DiffHit,
|
||||
DiffSelection,
|
||||
} from "./types";
|
||||
|
||||
const CODE_LEFT_PADDING = 8;
|
||||
|
||||
export function hitTestDiffDocument(input: {
|
||||
model: DiffDocumentModel;
|
||||
x: number;
|
||||
documentY: number;
|
||||
horizontalOffset: number;
|
||||
}): DiffHit | null {
|
||||
const file = fileAtDocumentOffset(input.model.files, input.documentY);
|
||||
if (!file) return null;
|
||||
if (input.documentY < file.bodyTop) return { kind: "header", path: file.path };
|
||||
const row = rowAtOffset(input.model.rows, file.rowStart, file.rowEnd, input.documentY);
|
||||
if (!row || row.kind !== "line") return null;
|
||||
const textHeight = row.height - row.reviewHeight;
|
||||
if (input.documentY >= row.top + textHeight) return null;
|
||||
const columnCount = row.cells.length;
|
||||
const columnWidth = input.model.viewportWidth / columnCount;
|
||||
const cellIndex = Math.min(columnCount - 1, Math.max(0, Math.floor(input.x / columnWidth)));
|
||||
const cell = row.cells[cellIndex];
|
||||
if (!cell) return null;
|
||||
const localX = input.x - cellIndex * columnWidth;
|
||||
const contentX = Math.max(
|
||||
0,
|
||||
localX - file.gutterWidth - CODE_LEFT_PADDING + input.horizontalOffset,
|
||||
);
|
||||
const textY = input.documentY - row.top;
|
||||
const sourceOffset = characterOffsetAtPoint({
|
||||
cell,
|
||||
x: contentX,
|
||||
y: textY,
|
||||
lineHeight: input.model.lineHeight,
|
||||
});
|
||||
return {
|
||||
kind: "cell",
|
||||
target: cell.reviewTarget,
|
||||
position: {
|
||||
fileIndex: file.fileIndex,
|
||||
rowIndex: row.index,
|
||||
cellIndex,
|
||||
side: cell.reviewTarget?.side ?? (cellIndex === 0 ? "old" : "new"),
|
||||
sourceOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function characterOffsetAtPoint(input: {
|
||||
cell: DiffCell;
|
||||
x: number;
|
||||
y: number;
|
||||
lineHeight: number;
|
||||
}): number {
|
||||
const fragmentIndex = Math.min(
|
||||
input.cell.fragments.length - 1,
|
||||
Math.max(0, Math.floor(input.y / input.lineHeight)),
|
||||
);
|
||||
const fragment = input.cell.fragments[fragmentIndex];
|
||||
if (!fragment) return 0;
|
||||
let width = 0;
|
||||
for (const grapheme of fragment.graphemes) {
|
||||
if (input.x <= width + grapheme.width / 2) return grapheme.start;
|
||||
width += grapheme.width;
|
||||
}
|
||||
return fragment.end;
|
||||
}
|
||||
|
||||
function rowAtOffset(
|
||||
rows: readonly DiffDocumentModel["rows"][number][],
|
||||
start: number,
|
||||
end: number,
|
||||
documentY: number,
|
||||
): DiffDocumentModel["rows"][number] | null {
|
||||
let low = start;
|
||||
let high = end;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
const row = rows[middle];
|
||||
if (row && row.top + row.height <= documentY) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const row = rows[low];
|
||||
return row && row.top <= documentY && documentY < row.top + row.height ? row : null;
|
||||
}
|
||||
|
||||
export function orderedSelection(selection: DiffSelection): {
|
||||
start: DiffCharacterPosition;
|
||||
end: DiffCharacterPosition;
|
||||
} {
|
||||
return comparePosition(selection.anchor, selection.focus) <= 0
|
||||
? { start: selection.anchor, end: selection.focus }
|
||||
: { start: selection.focus, end: selection.anchor };
|
||||
}
|
||||
|
||||
export function selectedSourceText(model: DiffDocumentModel, selection: DiffSelection): string {
|
||||
return selectedCellRanges(model, selection)
|
||||
.map(({ cell, start, end }) => cell.content.slice(start, end))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function comparePosition(left: DiffCharacterPosition, right: DiffCharacterPosition): number {
|
||||
return (
|
||||
left.rowIndex - right.rowIndex ||
|
||||
left.cellIndex - right.cellIndex ||
|
||||
left.sourceOffset - right.sourceOffset
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectedCellRange {
|
||||
cell: DiffCell;
|
||||
cellIndex: number;
|
||||
rowIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function selectedCellRanges(
|
||||
model: DiffDocumentModel,
|
||||
selection: DiffSelection,
|
||||
): SelectedCellRange[] {
|
||||
const { start, end } = orderedSelection(selection);
|
||||
const sameSplitSide = model.layout === "split" && start.cellIndex === end.cellIndex;
|
||||
const ranges: SelectedCellRange[] = [];
|
||||
for (let rowIndex = start.rowIndex; rowIndex <= end.rowIndex; rowIndex += 1) {
|
||||
const row = model.rows[rowIndex];
|
||||
if (!row || row.kind !== "line") continue;
|
||||
row.cells.forEach((cell, cellIndex) => {
|
||||
if (!cell || (sameSplitSide && cellIndex !== start.cellIndex)) return;
|
||||
const position = { rowIndex, cellIndex };
|
||||
if (compareCell(position, start) < 0 || compareCell(position, end) > 0) return;
|
||||
const rangeStart =
|
||||
rowIndex === start.rowIndex && cellIndex === start.cellIndex ? start.sourceOffset : 0;
|
||||
const rangeEnd =
|
||||
rowIndex === end.rowIndex && cellIndex === end.cellIndex
|
||||
? end.sourceOffset
|
||||
: cell.content.length;
|
||||
ranges.push({ cell, cellIndex, rowIndex, start: rangeStart, end: rangeEnd });
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function compareCell(
|
||||
left: Pick<DiffCharacterPosition, "rowIndex" | "cellIndex">,
|
||||
right: Pick<DiffCharacterPosition, "rowIndex" | "cellIndex">,
|
||||
): number {
|
||||
return left.rowIndex - right.rowIndex || left.cellIndex - right.cellIndex;
|
||||
}
|
||||
|
||||
export function selectionRectangles(input: {
|
||||
model: DiffDocumentModel;
|
||||
selection: DiffSelection;
|
||||
}): SelectionRectangle[] {
|
||||
const rectangles: SelectionRectangle[] = [];
|
||||
for (const range of selectedCellRanges(input.model, input.selection)) {
|
||||
const row = input.model.rows[range.rowIndex];
|
||||
if (!row || row.kind !== "line") continue;
|
||||
const file = input.model.files[row.fileIndex];
|
||||
if (!file) continue;
|
||||
const columnWidth = input.model.viewportWidth / row.cells.length;
|
||||
const cell = range.cell;
|
||||
for (const fragment of cell.fragments) {
|
||||
const partStart = Math.max(fragment.start, range.start);
|
||||
const partEnd = Math.min(fragment.end, range.end);
|
||||
if (partEnd <= partStart) continue;
|
||||
const before = fragmentWidthForRange(fragment, fragment.start, partStart);
|
||||
const selected = fragmentWidthForRange(fragment, partStart, partEnd);
|
||||
rectangles.push({
|
||||
x: range.cellIndex * columnWidth + file.gutterWidth + CODE_LEFT_PADDING + before,
|
||||
y: row.top + fragment.top,
|
||||
width: selected,
|
||||
height: input.model.lineHeight,
|
||||
fileIndex: row.fileIndex,
|
||||
clipX: range.cellIndex * columnWidth + file.gutterWidth + CODE_LEFT_PADDING,
|
||||
clipWidth: columnWidth - file.gutterWidth - CODE_LEFT_PADDING,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rectangles;
|
||||
}
|
||||
|
||||
interface SelectionRectangle {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
fileIndex: number;
|
||||
clipX: number;
|
||||
clipWidth: number;
|
||||
}
|
||||
Reference in New Issue
Block a user