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, right: Pick, ): 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; }