Files
paseo/packages/app/src/git/diff-document/surface.web.tsx
T

834 lines
29 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { ViewStyle } from "react-native";
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
import { InlineReviewAddButton, InlineReviewThread } from "@/review";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import { DocumentFileHeader } from "./document-file-header";
import { hitTestDiffDocument, selectedSourceText } from "./hit-testing";
import { retainHorizontalOffsetMapForPaths } from "./horizontal-offsets";
import { HorizontalScroll } from "./horizontal-scroll.web";
import {
buildDiffDocumentModel,
FILE_HEADER_HEIGHT,
resolveRelayoutScrollTop,
retainReusableModels,
} from "./model";
import { paintWebViewport } from "./paint.web";
import { hasPointerDragStarted } from "./pointer-gesture";
import { retainDiffViewport } from "./viewport";
import type {
DiffHit,
DiffSelection,
DiffSurfaceProps,
DiffTypography,
TextMeasurer,
} from "./types";
const DEFAULT_MONO_STACK = "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
const RESIZE_SETTLE_DELAY_MS = 120;
export function DiffSurface(props: DiffSurfaceProps) {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const canvasScratchRef = useRef<HTMLCanvasElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const modelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const previousModelRef = useRef<ReturnType<typeof buildDiffDocumentModel> | null>(null);
const reusableModelRef = useRef<{
dependencies: readonly unknown[];
models: ReturnType<typeof buildDiffDocumentModel>[];
} | null>(null);
const consumedFocusRef = useRef<string | null>(null);
const scrollTopRef = useRef(0);
const horizontalOffsetsRef = useRef(new Map<string, number>());
const selectionRef = useRef<DiffSelection | null>(null);
const dragRef = useRef<{
anchor: DiffSelection["anchor"];
startX: number;
startY: number;
moved: boolean;
dismissSelectionOnClick: boolean;
} | null>(null);
const frameRef = useRef<number | null>(null);
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeReleaseFrameRef = useRef<number | null>(null);
const resizePointerActiveRef = useRef(false);
const pendingViewportRef = useRef({ width: 0, height: 0 });
const forcePaintRef = useRef(true);
const canvasWindowRef = useRef({ top: 0, height: 0 });
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const [loadedTypography, setLoadedTypography] = useState<DiffTypography | null>(null);
const [hoveredAffordance, setHoveredAffordance] = useState<{
hit: Extract<DiffHit, { kind: "cell" }>;
left: number;
top: number;
} | null>(null);
const hasHoveredAffordanceRef = useRef(false);
const family = props.displayPreferences.monoFontFamily.trim() || DEFAULT_MONO_STACK;
useLayoutEffect(() => {
const stats = (window as typeof window & { __PASEO_DIFF_REACT_STATS__?: { commits: number } })
.__PASEO_DIFF_REACT_STATS__;
if (stats) stats.commits += 1;
});
const desiredTypography = useMemo<DiffTypography>(
() => ({
family,
size: props.displayPreferences.codeFontSize,
lineHeight: Math.round(props.displayPreferences.codeFontSize * 1.5),
}),
[family, props.displayPreferences.codeFontSize],
);
const measurement = useMemo(
() => (loadedTypography ? createWebTextMeasurer(loadedTypography) : null),
[loadedTypography],
);
const reviewActions = props.mode.kind === "working" ? props.mode.reviewActions : undefined;
const model = useMemo(() => {
if (!loadedTypography || !measurement) {
return emptyDiffDocumentModel({
layout: props.displayPreferences.layout,
wrapLines: props.displayPreferences.wrapLines,
viewportWidth: viewport.width,
lineHeight: desiredTypography.lineHeight,
});
}
const dependencies = [
props.files,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
viewport.width,
loadedTypography,
measurement,
props.palette,
t,
] as const;
const previous = reusableModelRef.current;
const canReuse = previous?.dependencies.every(
(dependency, index) => dependency === dependencies[index],
);
const reuseFrom = canReuse ? previous?.models : undefined;
const next = buildDiffDocumentModel({
files: props.files,
collapsedFilePaths: props.collapsedFilePaths,
layout: props.displayPreferences.layout,
wrapLines: props.displayPreferences.wrapLines,
viewportWidth: viewport.width,
typography: loadedTypography,
measureText: measurement,
palette: props.palette,
reviewActions,
labels: {
binary: t("workspace.git.diff.binaryFile"),
tooLarge: t("workspace.git.diff.tooLarge"),
},
reuseFrom,
});
reusableModelRef.current = {
dependencies,
models: retainReusableModels(reuseFrom, next),
};
return next;
}, [
measurement,
props.collapsedFilePaths,
props.displayPreferences.layout,
props.displayPreferences.wrapLines,
props.files,
props.palette,
reviewActions,
t,
desiredTypography.lineHeight,
loadedTypography,
viewport.width,
]);
modelRef.current = model;
const paint = useCallback(() => {
frameRef.current = null;
const canvas = canvasRef.current;
const currentModel = modelRef.current;
if (!canvas || !currentModel || currentModel.viewportWidth <= 0 || viewport.height <= 0) return;
const desiredHeight = Math.min(
Math.max(currentModel.height, viewport.height),
viewport.height * 3,
);
const currentWindow = canvasWindowRef.current;
const viewportTop = scrollTopRef.current;
const viewportBottom = viewportTop + viewport.height;
const safeInset = viewport.height / 2;
const mustRecenter =
currentWindow.height !== desiredHeight ||
viewportTop < currentWindow.top + safeInset ||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
if (!forcePaintRef.current && !mustRecenter) return;
const forceFullPaint = forcePaintRef.current;
forcePaintRef.current = false;
const canvasHeight = desiredHeight;
const ratio = window.devicePixelRatio || 1;
const requestedCanvasTop = mustRecenter
? Math.min(
Math.max(0, viewportTop - viewport.height),
Math.max(0, currentModel.height - canvasHeight),
)
: currentWindow.top;
const canvasTop = Math.round(requestedCanvasTop * ratio) / ratio;
canvasWindowRef.current = { top: canvasTop, height: canvasHeight };
const pixelWidth = Math.ceil(currentModel.viewportWidth * ratio);
const pixelHeight = Math.ceil(canvasHeight * ratio);
const resized = canvas.width !== pixelWidth || canvas.height !== pixelHeight;
if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
canvas.style.top = `${canvasTop}px`;
canvas.style.height = `${canvasHeight}px`;
const context = canvas.getContext("2d");
if (!context) return;
if (!measurement || !loadedTypography) return;
const { paintTop, paintHeight } = shiftCanvasPixels({
canvas,
scratch:
canvasScratchRef.current ?? (canvasScratchRef.current = document.createElement("canvas")),
context,
currentTop: currentWindow.top,
nextTop: canvasTop,
canvasHeight,
pixelWidth,
pixelHeight,
ratio,
canReuse: !forceFullPaint && !resized && currentWindow.height === canvasHeight,
});
paintWebViewport({
context,
model: currentModel,
palette: props.palette,
typography: loadedTypography,
measureText: measurement,
scrollTop: canvasTop,
viewportWidth: currentModel.viewportWidth,
viewportHeight: canvasHeight,
horizontalOffsets: horizontalOffsetsRef.current,
selection: selectionRef.current,
devicePixelRatio: ratio,
paintTop,
paintHeight,
});
}, [loadedTypography, measurement, props.palette, viewport.height]);
const schedulePaint = useCallback(
(force = true) => {
if (force) forcePaintRef.current = true;
if (frameRef.current === null) frameRef.current = requestAnimationFrame(paint);
},
[paint],
);
useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
const commitPendingViewport = () =>
setViewport((current) => retainDiffViewport(current, pendingViewportRef.current));
const clearResizeSettleTimer = () => {
if (resizeSettleTimerRef.current === null) return;
clearTimeout(resizeSettleTimerRef.current);
resizeSettleTimerRef.current = null;
};
const handlePointerDown = () => {
resizePointerActiveRef.current = true;
clearResizeSettleTimer();
};
const handlePointerEnd = () => {
if (!resizePointerActiveRef.current) return;
resizePointerActiveRef.current = false;
if (resizeReleaseFrameRef.current !== null) {
cancelAnimationFrame(resizeReleaseFrameRef.current);
}
resizeReleaseFrameRef.current = requestAnimationFrame(() => {
resizeReleaseFrameRef.current = null;
commitPendingViewport();
});
};
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
const nextViewport = { width: entry.contentRect.width, height: entry.contentRect.height };
pendingViewportRef.current = nextViewport;
if (resizePointerActiveRef.current) return;
setViewport((currentViewport) =>
currentViewport.width > 0 && currentViewport.height > 0
? currentViewport
: retainDiffViewport(currentViewport, nextViewport),
);
clearResizeSettleTimer();
resizeSettleTimerRef.current = setTimeout(() => {
resizeSettleTimerRef.current = null;
commitPendingViewport();
}, RESIZE_SETTLE_DELAY_MS);
});
observer.observe(root);
window.addEventListener("pointerdown", handlePointerDown, { capture: true });
window.addEventListener("pointerup", handlePointerEnd, { capture: true });
window.addEventListener("pointercancel", handlePointerEnd, { capture: true });
return () => {
observer.disconnect();
clearResizeSettleTimer();
if (resizeReleaseFrameRef.current !== null)
cancelAnimationFrame(resizeReleaseFrameRef.current);
window.removeEventListener("pointerdown", handlePointerDown, { capture: true });
window.removeEventListener("pointerup", handlePointerEnd, { capture: true });
window.removeEventListener("pointercancel", handlePointerEnd, { capture: true });
};
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
await Promise.all([
document.fonts.load(`400 ${desiredTypography.size}px ${desiredTypography.family}`),
document.fonts.load(`600 ${desiredTypography.size}px ${desiredTypography.family}`),
]);
await document.fonts.ready;
if (!cancelled) setLoadedTypography(desiredTypography);
})();
return () => {
cancelled = true;
};
}, [desiredTypography]);
useLayoutEffect(() => {
const previous = previousModelRef.current;
const scroll = scrollRef.current;
if (previous && scroll && previous !== model) {
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scroll.scrollTop);
scrollTopRef.current = nextScrollTop;
scroll.scrollTop = nextScrollTop;
}
previousModelRef.current = model;
}, [model]);
useLayoutEffect(() => {
horizontalOffsetsRef.current = retainHorizontalOffsetMapForPaths(
horizontalOffsetsRef.current,
props.files.map((file) => file.path),
);
}, [props.files]);
useLayoutEffect(schedulePaint, [model, schedulePaint]);
useEffect(
() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
// Fast Refresh preserves refs while rerunning effects. Release ownership of the canceled
// request so the refreshed renderer can schedule its first paint.
frameRef.current = null;
},
[],
);
const mode = props.mode;
const collapsedFilePaths = props.collapsedFilePaths;
const onToggleFile = props.onToggleFile;
useEffect(() => {
if (mode.kind !== "working") return;
const focusPath = mode.focusPath;
if (!focusPath) return;
const requestKey = `${mode.focusRequestId ?? "initial"}:${focusPath}`;
if (consumedFocusRef.current === requestKey) return;
if (collapsedFilePaths.has(focusPath)) onToggleFile(focusPath);
const file = model.files.find((entry) => entry.path === focusPath);
if (file && scrollRef.current) {
scrollRef.current.scrollTop = file.top;
consumedFocusRef.current = requestKey;
}
}, [collapsedFilePaths, mode, model.files, onToggleFile]);
const handleVerticalScroll = useCallback(
(scrollElement: HTMLDivElement) => {
const scrollTop = scrollElement.scrollTop;
scrollTopRef.current = scrollTop;
if (hasHoveredAffordanceRef.current) {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
}
const currentModel = modelRef.current;
const currentWindow = canvasWindowRef.current;
if (!currentModel || currentWindow.height === 0) {
schedulePaint(false);
return;
}
const viewportBottom = scrollTop + viewport.height;
const safeInset = viewport.height / 2;
const crossedSafeInset =
scrollTop < currentWindow.top + safeInset ||
viewportBottom > currentWindow.top + currentWindow.height - safeInset;
if (!crossedSafeInset) return;
const requestedTop = Math.min(
Math.max(0, scrollTop - viewport.height),
Math.max(0, currentModel.height - currentWindow.height),
);
const ratio = window.devicePixelRatio || 1;
const nextTop = Math.round(requestedTop * ratio) / ratio;
if (nextTop !== currentWindow.top) schedulePaint(false);
},
[schedulePaint, viewport.height],
);
useEffect(() => {
const scroll = scrollRef.current;
if (!scroll) return;
const onScroll = () => handleVerticalScroll(scroll);
scroll.addEventListener("scroll", onScroll, { passive: true });
return () => scroll.removeEventListener("scroll", onScroll);
}, [handleVerticalScroll]);
const handleHorizontalScroll = useCallback(
(path: string, offset: number) => {
horizontalOffsetsRef.current.set(path, offset);
schedulePaint();
},
[schedulePaint],
);
const pointHit = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const currentModel = modelRef.current;
const root = rootRef.current;
if (!currentModel || !root) return null;
const bounds = root.getBoundingClientRect();
const x = event.clientX - bounds.left;
const documentY = event.clientY - bounds.top + scrollTopRef.current;
const file = currentModel.files.find(
(entry) => entry.top <= documentY && documentY < entry.bottom,
);
return hitTestDiffDocument({
model: currentModel,
x,
documentY,
horizontalOffset: file ? (horizontalOffsetsRef.current.get(file.path) ?? 0) : 0,
});
}, []);
const pointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = event.target;
if (
target instanceof Element &&
target.closest(
'[data-diff-header="true"], [data-diff-review="true"], [role="menuitem"], button, input, textarea',
)
)
return;
const dismissSelectionOnClick = selectionRef.current !== null;
if (dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
}
const hit = pointHit(event);
if (hit?.kind !== "cell") return;
dragRef.current = {
anchor: hit.position,
startX: event.clientX,
startY: event.clientY,
moved: false,
dismissSelectionOnClick,
};
selectionRef.current = { anchor: hit.position, focus: hit.position };
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
schedulePaint();
},
[pointHit, schedulePaint],
);
const pointerMove = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (drag) {
drag.moved = hasPointerDragStarted({
startX: drag.startX,
startY: drag.startY,
x: event.clientX,
y: event.clientY,
alreadyDragging: drag.moved,
});
}
const hit = pointHit(event);
if (hit?.kind === "cell") {
const row = modelRef.current?.rows[hit.position.rowIndex];
const file = modelRef.current?.files[hit.position.fileIndex];
const sideIndex =
row?.kind === "line" && row.cells.length === 2 && hit.position.side === "new" ? 1 : 0;
if (row && file) {
const columnWidth = viewport.width / (row.kind === "line" ? row.cells.length : 1);
const gutterBorder = sideIndex * columnWidth + file.gutterWidth;
const rootBounds = rootRef.current?.getBoundingClientRect();
const pointerX = rootBounds ? event.clientX - rootBounds.left : Number.NaN;
if (hit.target && Number.isFinite(pointerX)) {
hasHoveredAffordanceRef.current = true;
setHoveredAffordance({
hit,
left: gutterBorder - 12,
top: row.top - scrollTopRef.current + (modelRef.current!.lineHeight - 22) / 2,
});
} else {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
}
}
} else {
hasHoveredAffordanceRef.current = false;
setHoveredAffordance(null);
}
if (!drag || hit?.kind !== "cell") return;
selectionRef.current = { anchor: drag.anchor, focus: hit.position };
schedulePaint();
},
[pointHit, schedulePaint, viewport.width],
);
const pointerUp = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
const hit = pointHit(event);
dragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
const moved = drag
? hasPointerDragStarted({
startX: drag.startX,
startY: drag.startY,
x: event.clientX,
y: event.clientY,
alreadyDragging: drag.moved,
})
: false;
if (drag && !moved && drag.dismissSelectionOnClick) {
selectionRef.current = null;
schedulePaint();
return;
}
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
selectionRef.current = null;
reviewActions.onStartComment(hit.target);
schedulePaint();
}
},
[pointHit, reviewActions, schedulePaint],
);
const cancelPointer = useCallback(() => {
dragRef.current = null;
}, []);
const copy = useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
if (!selectionRef.current) return;
event.clipboardData.setData("text/plain", selectedSourceText(model, selectionRef.current));
event.preventDefault();
},
[model],
);
const rootStyle = useMemo<React.CSSProperties>(
() => ({ ...ROOT_STYLE, background: props.palette.surface }),
[props.palette.surface],
);
const contentStyle = useMemo<React.CSSProperties>(
() => ({ ...CONTENT_STYLE, height: Math.max(model.height, viewport.height) }),
[model.height, viewport.height],
);
const affordanceStyle = useMemo<ViewStyle>(
() => ({
...AFFORDANCE_STYLE,
left: hoveredAffordance?.left ?? 0,
top: hoveredAffordance?.top ?? 0,
}),
[hoveredAffordance?.left, hoveredAffordance?.top],
);
const addHoveredComment = useCallback(() => {
const target = hoveredAffordance?.hit.target;
if (target) reviewActions?.onStartComment(target);
}, [hoveredAffordance?.hit.target, reviewActions]);
const canvasStyle = useMemo<React.CSSProperties>(
() => ({
...CANVAS_STYLE,
width: model.viewportWidth,
fontFamily: (loadedTypography ?? desiredTypography).family,
fontSize: (loadedTypography ?? desiredTypography).size,
}),
[desiredTypography, loadedTypography, model.viewportWidth],
);
return (
<div data-testid="git-diff-canvas-root" ref={rootRef} style={rootStyle}>
<div
ref={scrollRef}
data-testid="git-diff-scroll"
data-overlay-scrollbar="true"
tabIndex={0}
onPointerDown={pointerDown}
onPointerMove={pointerMove}
onPointerUp={pointerUp}
onPointerCancel={cancelPointer}
onLostPointerCapture={cancelPointer}
onCopy={copy}
style={SCROLL_STYLE}
>
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
{model.files.map((file) => (
<WebFileHeaderSection key={file.path} file={file}>
<DocumentFileHeader
file={file}
selectedPath={props.selectedPath}
mode={props.mode}
onToggleFile={props.onToggleFile}
onSelectPath={props.onSelectPath}
/>
</WebFileHeaderSection>
))}
{model.files
.filter((file) => !file.isCollapsed && !model.wrapLines)
.map((file) => (
<HorizontalScroll
key={file.path}
file={file}
initialOffset={horizontalOffsetsRef.current.get(file.path) ?? 0}
onScroll={handleHorizontalScroll}
/>
))}
{props.mode.kind === "working" && reviewActions
? model.rows.map((row) => {
if (row.kind !== "line" || row.reviewHeight === 0) return null;
const columnWidth = model.viewportWidth / row.cells.length;
return row.cells.map((cell, index) => {
if (!cell?.reviewTarget) return null;
const comments = reviewActions.commentsByTarget.get(cell.reviewTarget.key) ?? [];
const hasEditor =
reviewActions.editor?.target.filePath === cell.reviewTarget.filePath &&
reviewActions.editor.target.side === cell.reviewTarget.side &&
reviewActions.editor.target.lineNumber === cell.reviewTarget.lineNumber;
if (comments.length === 0 && !hasEditor) return null;
return (
<WebReviewThread
key={cell.reviewTarget.key}
target={cell.reviewTarget}
actions={reviewActions}
top={row.top + row.height - row.reviewHeight}
left={index * columnWidth}
width={columnWidth}
height={row.reviewHeight}
pinToViewport={!model.wrapLines}
/>
);
});
})
: null}
</div>
</div>
<DomOverlayScrollbar scrollContainerRef={scrollRef} onUserScrollUp={noop} />
{hoveredAffordance?.hit.target && reviewActions ? (
<InlineReviewAddButton onPress={addHoveredComment} style={affordanceStyle} />
) : null}
</div>
);
}
function shiftCanvasPixels(input: {
canvas: HTMLCanvasElement;
scratch: HTMLCanvasElement;
context: CanvasRenderingContext2D;
currentTop: number;
nextTop: number;
canvasHeight: number;
pixelWidth: number;
pixelHeight: number;
ratio: number;
canReuse: boolean;
}): { paintTop: number; paintHeight: number } {
const delta = input.nextTop - input.currentTop;
if (!input.canReuse || delta === 0 || Math.abs(delta) >= input.canvasHeight) {
return { paintTop: 0, paintHeight: input.canvasHeight };
}
const deltaPixels = Math.round(Math.abs(delta) * input.ratio);
const overlapPixels = input.pixelHeight - deltaPixels;
if (input.scratch.width !== input.pixelWidth) input.scratch.width = input.pixelWidth;
if (input.scratch.height !== input.pixelHeight) input.scratch.height = input.pixelHeight;
const scratchContext = input.scratch.getContext("2d");
if (!scratchContext) return { paintTop: 0, paintHeight: input.canvasHeight };
scratchContext.setTransform(1, 0, 0, 1, 0, 0);
scratchContext.globalCompositeOperation = "copy";
scratchContext.drawImage(input.canvas, 0, 0);
input.context.setTransform(1, 0, 0, 1, 0, 0);
if (delta > 0) {
input.context.drawImage(
input.scratch,
0,
deltaPixels,
input.pixelWidth,
overlapPixels,
0,
0,
input.pixelWidth,
overlapPixels,
);
return { paintTop: input.canvasHeight - Math.abs(delta), paintHeight: Math.abs(delta) };
}
input.context.drawImage(
input.scratch,
0,
0,
input.pixelWidth,
overlapPixels,
0,
deltaPixels,
input.pixelWidth,
overlapPixels,
);
return { paintTop: 0, paintHeight: Math.abs(delta) };
}
function WebFileHeaderSection({
file,
children,
}: {
file: ReturnType<typeof buildDiffDocumentModel>["files"][number];
children: React.ReactNode;
}) {
const style = useMemo<React.CSSProperties>(
() => ({ ...FILE_SECTION_STYLE, top: file.top, height: file.bottom - file.top }),
[file.bottom, file.top],
);
return (
<div style={style}>
<div
data-diff-header="true"
style={STICKY_HEADER_STYLE}
onMouseDown={preventDocumentMouseSelection}
>
{children}
</div>
<div data-testid={`diff-file-${file.fileIndex}-body`} style={BODY_MARKER_STYLE} />
</div>
);
}
function preventDocumentMouseSelection(event: React.MouseEvent): void {
if (event.detail < 2) return;
const target = event.target;
if (target instanceof Element && target.closest('[data-diff-review="true"]')) return;
event.preventDefault();
window.getSelection()?.removeAllRanges();
}
function WebReviewThread({
target,
actions,
top,
left,
width,
height,
pinToViewport,
}: {
target: ReviewableDiffTarget;
actions: NonNullable<Extract<DiffSurfaceProps["mode"], { kind: "working" }>["reviewActions"]>;
top: number;
left: number;
width: number;
height: number;
pinToViewport: boolean;
}) {
const style = useMemo<React.CSSProperties>(
() => ({ ...REVIEW_STYLE, top, left, width, height }),
[height, left, top, width],
);
return (
<div style={style} data-diff-review="true">
<InlineReviewThread
reviewTarget={target}
reviewActions={actions}
height={height}
viewportWidth={width}
pinToViewport={pinToViewport}
/>
</div>
);
}
const ROOT_STYLE: React.CSSProperties = {
position: "relative",
flex: 1,
minHeight: 0,
overflow: "hidden",
};
const SCROLL_STYLE: React.CSSProperties = {
position: "absolute",
inset: 0,
zIndex: 2,
overflowY: "auto",
overflowX: "hidden",
scrollbarWidth: "none",
cursor: "text",
};
function noop(): void {}
const CONTENT_STYLE: React.CSSProperties = {
position: "relative",
width: "100%",
userSelect: "none",
};
const FILE_SECTION_STYLE: React.CSSProperties = {
position: "absolute",
left: 0,
right: 0,
zIndex: 3,
pointerEvents: "none",
};
const STICKY_HEADER_STYLE: React.CSSProperties = {
position: "sticky",
top: 0,
pointerEvents: "auto",
userSelect: "none",
};
const BODY_MARKER_STYLE: React.CSSProperties = {
position: "absolute",
top: FILE_HEADER_HEIGHT,
bottom: 0,
left: 0,
right: 0,
pointerEvents: "none",
};
const REVIEW_STYLE: React.CSSProperties = { position: "absolute", zIndex: 4, userSelect: "text" };
const CANVAS_STYLE: React.CSSProperties = {
position: "absolute",
top: 0,
left: 0,
zIndex: 1,
pointerEvents: "none",
};
const AFFORDANCE_STYLE: ViewStyle = {
position: "absolute",
zIndex: 5,
};
function emptyDiffDocumentModel(input: {
layout: "unified" | "split";
wrapLines: boolean;
viewportWidth: number;
lineHeight: number;
}): ReturnType<typeof buildDiffDocumentModel> {
return {
files: [],
rows: [],
height: 0,
lineHeight: input.lineHeight,
layout: input.layout,
wrapLines: input.wrapLines,
viewportWidth: input.viewportWidth,
};
}
function createWebTextMeasurer(typography: DiffTypography): TextMeasurer {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return { measure: () => 0 };
return {
measure(text, weight = "regular") {
context.font = `${weight === "semibold" ? 600 : 400} ${typography.size}px ${typography.family}`;
return context.measureText(text).width;
},
measureAdvances(graphemes) {
context.font = `${typography.size}px ${typography.family}`;
let prefix = "";
return graphemes.map((grapheme) => {
prefix += grapheme;
return context.measureText(prefix).width;
});
},
};
}