fix(app): stabilize retained canvas diff rendering
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
measureFragments,
|
measureFragments,
|
||||||
resolveScrollAnchor,
|
resolveScrollAnchor,
|
||||||
resolveRelayoutScrollTop,
|
resolveRelayoutScrollTop,
|
||||||
|
shouldApplyRelayoutScroll,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
import type { BuildDiffDocumentModelInput, TextMeasurer } from "./types";
|
||||||
|
|
||||||
@@ -65,6 +66,12 @@ function input(overrides: Partial<BuildDiffDocumentModelInput> = {}): BuildDiffD
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("diff document model", () => {
|
describe("diff document model", () => {
|
||||||
|
it("does not imperatively scroll for a no-op relayout", () => {
|
||||||
|
expect(shouldApplyRelayoutScroll(320, 320)).toBe(false);
|
||||||
|
expect(shouldApplyRelayoutScroll(320, 320.4)).toBe(false);
|
||||||
|
expect(shouldApplyRelayoutScroll(320, 321)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("segments graphemes when Intl.Segmenter is unavailable", () => {
|
it("segments graphemes when Intl.Segmenter is unavailable", () => {
|
||||||
const originalSegmenter = Intl.Segmenter;
|
const originalSegmenter = Intl.Segmenter;
|
||||||
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
|
Object.defineProperty(Intl, "Segmenter", { configurable: true, value: undefined });
|
||||||
|
|||||||
@@ -172,10 +172,9 @@ export function buildDiffDocumentModel(input: BuildDiffDocumentModelInput): Diff
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function finalExpandedBodyBorderTop(model: DiffDocumentModel): number | null {
|
export function expandedBodyBorderTop(file: DiffFileSection): number | null {
|
||||||
"worklet";
|
"worklet";
|
||||||
const file = model.files[model.files.length - 1];
|
return file.isCollapsed ? null : file.bottom - DIFF_BODY_BORDER_HEIGHT;
|
||||||
return file && !file.isCollapsed ? file.bottom - DIFF_BODY_BORDER_HEIGHT : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function retainReusableModels(
|
export function retainReusableModels(
|
||||||
@@ -579,6 +578,13 @@ export function resolveRelayoutScrollTop(
|
|||||||
return anchor ? resolveScrollAnchor(next, anchor) : scrollTop;
|
return anchor ? resolveScrollAnchor(next, anchor) : scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldApplyRelayoutScroll(
|
||||||
|
currentScrollTop: number,
|
||||||
|
nextScrollTop: number,
|
||||||
|
): boolean {
|
||||||
|
return Math.abs(nextScrollTop - currentScrollTop) > 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
function sameSourceIdentity(left: DiffSourceIdentity, right: DiffSourceIdentity): boolean {
|
||||||
return (
|
return (
|
||||||
left.hunkIndex === right.hunkIndex &&
|
left.hunkIndex === right.hunkIndex &&
|
||||||
|
|||||||
@@ -78,4 +78,25 @@ describe("native canvas slabs", () => {
|
|||||||
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
expect(nativeCanvasWindowBucket(899, 600)).toBe(2);
|
||||||
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
expect(nativeCanvasWindowTop(2, 600)).toBe(600);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps slab identities stable when inline review geometry moves files", () => {
|
||||||
|
const before = buildNativeCanvasSlabs(
|
||||||
|
modelWithBodies([
|
||||||
|
{ path: "first.ts", bodyTop: 30, bottom: 230 },
|
||||||
|
{ path: "second.ts", bodyTop: 260, bottom: 3460 },
|
||||||
|
]),
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
const after = buildNativeCanvasSlabs(
|
||||||
|
modelWithBodies([
|
||||||
|
{ path: "first.ts", bodyTop: 30, bottom: 378 },
|
||||||
|
{ path: "second.ts", bodyTop: 408, bottom: 3608 },
|
||||||
|
]),
|
||||||
|
600,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(after.filter((slab) => slab.path === "second.ts").map((slab) => slab.key)).toEqual(
|
||||||
|
before.filter((slab) => slab.path === "second.ts").map((slab) => slab.key),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export function buildNativeCanvasSlabs(
|
|||||||
const slabHeight = Math.max(768, viewportHeight * 2);
|
const slabHeight = Math.max(768, viewportHeight * 2);
|
||||||
return model.files.flatMap((file) => {
|
return model.files.flatMap((file) => {
|
||||||
const slabs: NativeCanvasSlab[] = [];
|
const slabs: NativeCanvasSlab[] = [];
|
||||||
for (let top = file.bodyTop; top < file.bottom; top += slabHeight) {
|
let slabIndex = 0;
|
||||||
|
for (let top = file.bodyTop; top < file.bottom; top += slabHeight, slabIndex += 1) {
|
||||||
const height = Math.min(slabHeight, file.bottom - top);
|
const height = Math.min(slabHeight, file.bottom - top);
|
||||||
slabs.push({
|
slabs.push({
|
||||||
key: `${file.path}:${top}:${height}`,
|
key: `${file.path}:${slabIndex}`,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
fileIndex: file.fileIndex,
|
fileIndex: file.fileIndex,
|
||||||
top,
|
top,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type SkPaint,
|
type SkPaint,
|
||||||
type SkPicture,
|
type SkPicture,
|
||||||
} from "@shopify/react-native-skia";
|
} from "@shopify/react-native-skia";
|
||||||
import { DIFF_BODY_BORDER_HEIGHT, finalExpandedBodyBorderTop, visibleRowRange } from "./model";
|
import { DIFF_BODY_BORDER_HEIGHT, expandedBodyBorderTop, visibleRowRange } from "./model";
|
||||||
import { nativeTextRuns } from "./native-text-runs";
|
import { nativeTextRuns } from "./native-text-runs";
|
||||||
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
import { horizontalOffsetForPath, type DiffHorizontalOffsets } from "./horizontal-offsets";
|
||||||
import { codeLineNumberTone } from "./palette";
|
import { codeLineNumberTone } from "./palette";
|
||||||
@@ -102,6 +102,7 @@ export function recordNativeSlabPictures(input: {
|
|||||||
textLayout: input.textLayout,
|
textLayout: input.textLayout,
|
||||||
paints: input.paints,
|
paints: input.paints,
|
||||||
layer: "fixed",
|
layer: "fixed",
|
||||||
|
fileIndex: input.fileIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recordContent = (contentCell: "unified" | "left" | "right") => {
|
const recordContent = (contentCell: "unified" | "left" | "right") => {
|
||||||
@@ -193,6 +194,7 @@ interface PaintNativeRangeInput {
|
|||||||
paints: NativePaints;
|
paints: NativePaints;
|
||||||
layer: "all" | "fixed" | "content";
|
layer: "all" | "fixed" | "content";
|
||||||
contentCell?: "unified" | "left" | "right";
|
contentCell?: "unified" | "left" | "right";
|
||||||
|
fileIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintNativeRange(input: PaintNativeRangeInput): void {
|
function paintNativeRange(input: PaintNativeRangeInput): void {
|
||||||
@@ -257,15 +259,20 @@ function paintNativeRange(input: PaintNativeRangeInput): void {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const finalBorderTop = paintsFixedContent ? finalExpandedBodyBorderTop(input.model) : null;
|
let borderFiles = input.model.files;
|
||||||
if (finalBorderTop !== null) {
|
if (input.fileIndex !== undefined) {
|
||||||
|
const file = input.model.files[input.fileIndex];
|
||||||
|
borderFiles = file ? [file] : [];
|
||||||
|
}
|
||||||
|
for (const file of paintsFixedContent ? borderFiles : []) {
|
||||||
|
const borderTop = expandedBodyBorderTop(file);
|
||||||
|
const isVisible =
|
||||||
|
borderTop !== null &&
|
||||||
|
borderTop < input.scrollTop + input.viewportHeight &&
|
||||||
|
borderTop + DIFF_BODY_BORDER_HEIGHT > input.scrollTop;
|
||||||
|
if (!isVisible) continue;
|
||||||
input.canvas.drawRect(
|
input.canvas.drawRect(
|
||||||
Skia.XYWHRect(
|
Skia.XYWHRect(0, borderTop - input.scrollTop, input.viewportWidth, DIFF_BODY_BORDER_HEIGHT),
|
||||||
0,
|
|
||||||
finalBorderTop - input.scrollTop,
|
|
||||||
input.viewportWidth,
|
|
||||||
DIFF_BODY_BORDER_HEIGHT,
|
|
||||||
),
|
|
||||||
input.paints.border,
|
input.paints.border,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe("web diff text shaping", () => {
|
|||||||
expect(fills).toContainEqual({ color: "border", x: 20, y: 0, width: 1, height: 58 });
|
expect(fills).toContainEqual({ color: "border", x: 20, y: 0, width: 1, height: 58 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("paints the final expanded file body's bottom border", () => {
|
it("paints every expanded file body's bottom border", () => {
|
||||||
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
|
const fills: Array<{ color: string; x: number; y: number; width: number; height: number }> = [];
|
||||||
let fillStyle = "";
|
let fillStyle = "";
|
||||||
const context = {
|
const context = {
|
||||||
@@ -111,8 +111,22 @@ describe("web diff text shaping", () => {
|
|||||||
} as unknown as CanvasRenderingContext2D;
|
} as unknown as CanvasRenderingContext2D;
|
||||||
const borderedModel: DiffDocumentModel = {
|
const borderedModel: DiffDocumentModel = {
|
||||||
...model,
|
...model,
|
||||||
height: 19,
|
height: 38,
|
||||||
files: [{ ...model.files[0]!, bodyHeight: 19, bottom: 19 }],
|
files: [
|
||||||
|
{ ...model.files[0]!, bodyHeight: 19, bottom: 19 },
|
||||||
|
{
|
||||||
|
...model.files[0]!,
|
||||||
|
file: { ...model.files[0]!.file, path: "src/b.ts" },
|
||||||
|
fileIndex: 1,
|
||||||
|
path: "src/b.ts",
|
||||||
|
top: 19,
|
||||||
|
bodyTop: 19,
|
||||||
|
bodyHeight: 19,
|
||||||
|
bottom: 38,
|
||||||
|
rowStart: 1,
|
||||||
|
rowEnd: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
paintWebViewport({
|
paintWebViewport({
|
||||||
@@ -129,7 +143,10 @@ describe("web diff text shaping", () => {
|
|||||||
devicePixelRatio: 1,
|
devicePixelRatio: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fills).toContainEqual({ color: "border", x: 0, y: 18, width: 200, height: 1 });
|
expect(fills.filter((fill) => fill.color === "border" && fill.x === 0)).toEqual([
|
||||||
|
{ color: "border", x: 0, y: 18, width: 200, height: 1 },
|
||||||
|
{ color: "border", x: 0, y: 37, width: 200, height: 1 },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("clips a horizontally scrolled unified selection to the code viewport", () => {
|
it("clips a horizontally scrolled unified selection to the code viewport", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { selectionRectangles } from "./hit-testing";
|
import { selectionRectangles } from "./hit-testing";
|
||||||
import {
|
import {
|
||||||
DIFF_BODY_BORDER_HEIGHT,
|
DIFF_BODY_BORDER_HEIGHT,
|
||||||
finalExpandedBodyBorderTop,
|
expandedBodyBorderTop,
|
||||||
fragmentWidthForRange,
|
fragmentWidthForRange,
|
||||||
visibleRowRange,
|
visibleRowRange,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
@@ -69,15 +69,17 @@ export function paintWebViewport(input: PaintWebViewportInput): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalBorderTop = finalExpandedBodyBorderTop(input.model);
|
context.fillStyle = input.palette.border;
|
||||||
if (finalBorderTop !== null) {
|
const paintDocumentTop = input.scrollTop + paintTop;
|
||||||
context.fillStyle = input.palette.border;
|
const paintDocumentBottom = paintDocumentTop + paintHeight;
|
||||||
context.fillRect(
|
for (const file of input.model.files) {
|
||||||
0,
|
const borderTop = expandedBodyBorderTop(file);
|
||||||
finalBorderTop - input.scrollTop,
|
const isVisible =
|
||||||
input.viewportWidth,
|
borderTop !== null &&
|
||||||
DIFF_BODY_BORDER_HEIGHT,
|
borderTop < paintDocumentBottom &&
|
||||||
);
|
borderTop + DIFF_BODY_BORDER_HEIGHT > paintDocumentTop;
|
||||||
|
if (!isVisible) continue;
|
||||||
|
context.fillRect(0, borderTop - input.scrollTop, input.viewportWidth, DIFF_BODY_BORDER_HEIGHT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.selection) paintSelection(input, input.selection);
|
if (input.selection) paintSelection(input, input.selection);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Canvas, Picture, type SkPicture } from "@shopify/react-native-skia";
|
import { Canvas, Group, Picture, type SkPicture } from "@shopify/react-native-skia";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -9,26 +9,34 @@ import {
|
|||||||
type LayoutChangeEvent,
|
type LayoutChangeEvent,
|
||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import {
|
import Animated, {
|
||||||
createAnimatedComponent,
|
createAnimatedComponent,
|
||||||
useAnimatedReaction,
|
useAnimatedReaction,
|
||||||
useAnimatedScrollHandler,
|
useAnimatedScrollHandler,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
|
useDerivedValue,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
type SharedValue,
|
type SharedValue,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { scheduleOnRN } from "react-native-worklets";
|
import { scheduleOnRN } from "react-native-worklets";
|
||||||
import { InlineReviewThread } from "@/review";
|
import { InlineReviewThread } from "@/review";
|
||||||
|
import { useKeyboardShift } from "@/hooks/keyboard-shift-context";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import { DocumentFileHeader } from "./document-file-header";
|
import { DocumentFileHeader } from "./document-file-header";
|
||||||
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
import { hitTestDiffBodyPoint } from "./native-hit-testing";
|
||||||
|
import { retainDiffViewport } from "./viewport";
|
||||||
import { HorizontalScroll } from "./horizontal-scroll.native";
|
import { HorizontalScroll } from "./horizontal-scroll.native";
|
||||||
import {
|
import {
|
||||||
horizontalOffsetForPath,
|
horizontalOffsetForPath,
|
||||||
retainHorizontalOffsetsForPaths,
|
retainHorizontalOffsetsForPaths,
|
||||||
type DiffHorizontalOffsets,
|
type DiffHorizontalOffsets,
|
||||||
} from "./horizontal-offsets";
|
} from "./horizontal-offsets";
|
||||||
import { buildDiffDocumentModel, resolveRelayoutScrollTop, retainReusableModels } from "./model";
|
import {
|
||||||
|
buildDiffDocumentModel,
|
||||||
|
resolveRelayoutScrollTop,
|
||||||
|
retainReusableModels,
|
||||||
|
shouldApplyRelayoutScroll,
|
||||||
|
} from "./model";
|
||||||
import {
|
import {
|
||||||
buildNativeCanvasSlabs,
|
buildNativeCanvasSlabs,
|
||||||
nativeCanvasSlabsForViewport,
|
nativeCanvasSlabsForViewport,
|
||||||
@@ -54,7 +62,6 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
const AnimatedScrollView = createAnimatedComponent(ScrollView);
|
const AnimatedScrollView = createAnimatedComponent(ScrollView);
|
||||||
const AnimatedCodeLayer = createAnimatedComponent(View);
|
|
||||||
const SYSTEM_MONO = "monospace";
|
const SYSTEM_MONO = "monospace";
|
||||||
const CODE_LEFT_PADDING = 8;
|
const CODE_LEFT_PADDING = 8;
|
||||||
|
|
||||||
@@ -69,6 +76,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const consumedFocusRef = useRef<string | null>(null);
|
const consumedFocusRef = useRef<string | null>(null);
|
||||||
const scrollTop = useSharedValue(0);
|
const scrollTop = useSharedValue(0);
|
||||||
|
const { shift: keyboardShift } = useKeyboardShift();
|
||||||
|
const keyboardSpacerStyle = useAnimatedStyle(
|
||||||
|
() => ({ height: keyboardShift.value }),
|
||||||
|
[keyboardShift],
|
||||||
|
);
|
||||||
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
const horizontalOffsets = useSharedValue<DiffHorizontalOffsets>({});
|
||||||
const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO;
|
const family = props.displayPreferences.monoFontFamily.trim() || SYSTEM_MONO;
|
||||||
const typography = useMemo<DiffTypography>(
|
const typography = useMemo<DiffTypography>(
|
||||||
@@ -101,7 +113,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
);
|
);
|
||||||
const reuseFrom = canReuse ? previous?.models : undefined;
|
const reuseFrom = canReuse ? previous?.models : undefined;
|
||||||
const next = buildDiffDocumentModel({
|
const next = buildDiffDocumentModel({
|
||||||
files: props.files,
|
files: viewport.width > 0 ? props.files : [],
|
||||||
collapsedFilePaths: props.collapsedFilePaths,
|
collapsedFilePaths: props.collapsedFilePaths,
|
||||||
layout: props.displayPreferences.layout,
|
layout: props.displayPreferences.layout,
|
||||||
wrapLines: props.displayPreferences.wrapLines,
|
wrapLines: props.displayPreferences.wrapLines,
|
||||||
@@ -156,16 +168,16 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
|
|
||||||
const layout = useCallback((event: LayoutChangeEvent) => {
|
const layout = useCallback((event: LayoutChangeEvent) => {
|
||||||
const { width, height } = event.nativeEvent.layout;
|
const { width, height } = event.nativeEvent.layout;
|
||||||
setViewport((current) =>
|
setViewport((current) => retainDiffViewport(current, { width, height }));
|
||||||
current.width === width && current.height === height ? current : { width, height },
|
|
||||||
);
|
|
||||||
}, []);
|
}, []);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const previous = previousModelRef.current;
|
const previous = previousModelRef.current;
|
||||||
if (previous && previous !== model) {
|
if (previous && previous !== model) {
|
||||||
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value);
|
const nextScrollTop = resolveRelayoutScrollTop(previous, model, scrollTop.value);
|
||||||
scrollTop.value = nextScrollTop;
|
if (shouldApplyRelayoutScroll(scrollTop.value, nextScrollTop)) {
|
||||||
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
scrollTop.value = nextScrollTop;
|
||||||
|
scrollRef.current?.scrollTo({ y: nextScrollTop, animated: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
previousModelRef.current = model;
|
previousModelRef.current = model;
|
||||||
}, [model, scrollTop]);
|
}, [model, scrollTop]);
|
||||||
@@ -201,7 +213,6 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
() => nativeStickyHeaderIndices(model.files.length),
|
() => nativeStickyHeaderIndices(model.files.length),
|
||||||
[model.files.length],
|
[model.files.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
<View style={[styles.root, { backgroundColor: props.palette.surface }]} onLayout={layout}>
|
||||||
<AnimatedScrollView
|
<AnimatedScrollView
|
||||||
@@ -222,11 +233,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
paints={paints}
|
paints={paints}
|
||||||
horizontalOffsets={horizontalOffsets}
|
horizontalOffsets={horizontalOffsets}
|
||||||
/>
|
/>
|
||||||
{model.files.flatMap((file, index) => [
|
{model.files.flatMap((file) => [
|
||||||
<NativeStickyFileHeader
|
<NativeStickyFileHeader
|
||||||
key={`${file.path}:header`}
|
key={`${file.path}:header`}
|
||||||
file={file}
|
file={file}
|
||||||
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
|
|
||||||
selectedPath={props.selectedPath}
|
selectedPath={props.selectedPath}
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
onToggleFile={props.onToggleFile}
|
onToggleFile={props.onToggleFile}
|
||||||
@@ -241,6 +251,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
/>,
|
/>,
|
||||||
])}
|
])}
|
||||||
<NativeReviewOverlays model={model} mode={props.mode} />
|
<NativeReviewOverlays model={model} mode={props.mode} />
|
||||||
|
<Animated.View pointerEvents="none" style={keyboardSpacerStyle} />
|
||||||
</AnimatedScrollView>
|
</AnimatedScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -282,6 +293,7 @@ function NativeCanvasSlabLayer({
|
|||||||
() => nativeCanvasSlabsForViewport(descriptors, windowTop, viewportHeight),
|
() => nativeCanvasSlabsForViewport(descriptors, windowTop, viewportHeight),
|
||||||
[descriptors, viewportHeight, windowTop],
|
[descriptors, viewportHeight, windowTop],
|
||||||
);
|
);
|
||||||
|
if (model.viewportWidth <= 0 || viewportHeight <= 0) return null;
|
||||||
return visible.map((slab) => (
|
return visible.map((slab) => (
|
||||||
<NativeCanvasSlabView
|
<NativeCanvasSlabView
|
||||||
key={slab.key}
|
key={slab.key}
|
||||||
@@ -296,14 +308,12 @@ function NativeCanvasSlabLayer({
|
|||||||
|
|
||||||
function NativeStickyFileHeader({
|
function NativeStickyFileHeader({
|
||||||
file,
|
file,
|
||||||
showTopBorder,
|
|
||||||
selectedPath,
|
selectedPath,
|
||||||
mode,
|
mode,
|
||||||
onToggleFile,
|
onToggleFile,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
}: {
|
}: {
|
||||||
file: DiffFileSection;
|
file: DiffFileSection;
|
||||||
showTopBorder: boolean;
|
|
||||||
selectedPath: DiffSurfaceProps["selectedPath"];
|
selectedPath: DiffSurfaceProps["selectedPath"];
|
||||||
mode: DiffSurfaceProps["mode"];
|
mode: DiffSurfaceProps["mode"];
|
||||||
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
onToggleFile: DiffSurfaceProps["onToggleFile"];
|
||||||
@@ -313,7 +323,6 @@ function NativeStickyFileHeader({
|
|||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<DocumentFileHeader
|
<DocumentFileHeader
|
||||||
file={file}
|
file={file}
|
||||||
showTopBorder={showTopBorder}
|
|
||||||
selectedPath={selectedPath}
|
selectedPath={selectedPath}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
onToggleFile={onToggleFile}
|
onToggleFile={onToggleFile}
|
||||||
@@ -491,7 +500,6 @@ function NativeCanvasSlabView({
|
|||||||
wrapLines={model.wrapLines}
|
wrapLines={model.wrapLines}
|
||||||
clipX={unifiedClip.x}
|
clipX={unifiedClip.x}
|
||||||
clipWidth={unifiedClip.width}
|
clipWidth={unifiedClip.width}
|
||||||
contentWidth={file.contentWidth}
|
|
||||||
height={slab.height}
|
height={slab.height}
|
||||||
/>
|
/>
|
||||||
{model.layout === "split" ? (
|
{model.layout === "split" ? (
|
||||||
@@ -503,7 +511,6 @@ function NativeCanvasSlabView({
|
|||||||
wrapLines={model.wrapLines}
|
wrapLines={model.wrapLines}
|
||||||
clipX={file.gutterWidth + CODE_LEFT_PADDING}
|
clipX={file.gutterWidth + CODE_LEFT_PADDING}
|
||||||
clipWidth={splitClipWidth}
|
clipWidth={splitClipWidth}
|
||||||
contentWidth={file.contentWidth}
|
|
||||||
height={slab.height}
|
height={slab.height}
|
||||||
/>
|
/>
|
||||||
<NativeSlabCode
|
<NativeSlabCode
|
||||||
@@ -513,7 +520,6 @@ function NativeCanvasSlabView({
|
|||||||
wrapLines={model.wrapLines}
|
wrapLines={model.wrapLines}
|
||||||
clipX={columnWidth + file.gutterWidth + CODE_LEFT_PADDING}
|
clipX={columnWidth + file.gutterWidth + CODE_LEFT_PADDING}
|
||||||
clipWidth={splitClipWidth}
|
clipWidth={splitClipWidth}
|
||||||
contentWidth={file.contentWidth}
|
|
||||||
height={slab.height}
|
height={slab.height}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -532,7 +538,6 @@ function NativeSlabCode({
|
|||||||
wrapLines,
|
wrapLines,
|
||||||
clipX,
|
clipX,
|
||||||
clipWidth,
|
clipWidth,
|
||||||
contentWidth,
|
|
||||||
height,
|
height,
|
||||||
}: {
|
}: {
|
||||||
picture: SkPicture;
|
picture: SkPicture;
|
||||||
@@ -541,14 +546,13 @@ function NativeSlabCode({
|
|||||||
wrapLines: boolean;
|
wrapLines: boolean;
|
||||||
clipX: number;
|
clipX: number;
|
||||||
clipWidth: number;
|
clipWidth: number;
|
||||||
contentWidth: number;
|
|
||||||
height: number;
|
height: number;
|
||||||
}) {
|
}) {
|
||||||
const transform = useAnimatedStyle(() => ({
|
const pictureTransform = useDerivedValue(() => [
|
||||||
transform: [
|
{
|
||||||
{ translateX: wrapLines ? 0 : -horizontalOffsetForPath(horizontalOffsets.value, path) },
|
translateX: -clipX - (wrapLines ? 0 : horizontalOffsetForPath(horizontalOffsets.value, path)),
|
||||||
],
|
},
|
||||||
}));
|
]);
|
||||||
const clipStyle = useMemo<ViewStyle>(
|
const clipStyle = useMemo<ViewStyle>(
|
||||||
() => ({
|
() => ({
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
@@ -560,17 +564,14 @@ function NativeSlabCode({
|
|||||||
}),
|
}),
|
||||||
[clipWidth, clipX, height],
|
[clipWidth, clipX, height],
|
||||||
);
|
);
|
||||||
const contentStyle = useMemo<ViewStyle>(
|
if (clipWidth <= 0 || height <= 0) return null;
|
||||||
() => ({ position: "absolute", top: 0, left: -clipX, width: contentWidth, height }),
|
|
||||||
[clipX, contentWidth, height],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<View pointerEvents="none" style={clipStyle}>
|
<View pointerEvents="none" style={clipStyle}>
|
||||||
<AnimatedCodeLayer style={[contentStyle, transform]}>
|
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
|
||||||
<Canvas style={StyleSheet.absoluteFill}>
|
<Group transform={pictureTransform}>
|
||||||
<Picture picture={picture} />
|
<Picture picture={picture} />
|
||||||
</Canvas>
|
</Group>
|
||||||
</AnimatedCodeLayer>
|
</Canvas>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "./model";
|
} from "./model";
|
||||||
import { paintWebViewport } from "./paint.web";
|
import { paintWebViewport } from "./paint.web";
|
||||||
import { hasPointerDragStarted } from "./pointer-gesture";
|
import { hasPointerDragStarted } from "./pointer-gesture";
|
||||||
|
import { retainDiffViewport } from "./viewport";
|
||||||
import type {
|
import type {
|
||||||
DiffHit,
|
DiffHit,
|
||||||
DiffSelection,
|
DiffSelection,
|
||||||
@@ -48,6 +49,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
moved: boolean;
|
moved: boolean;
|
||||||
|
dismissSelectionOnClick: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const frameRef = useRef<number | null>(null);
|
const frameRef = useRef<number | null>(null);
|
||||||
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const resizeSettleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -223,7 +225,8 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const commitPendingViewport = () => setViewport(pendingViewportRef.current);
|
const commitPendingViewport = () =>
|
||||||
|
setViewport((current) => retainDiffViewport(current, pendingViewportRef.current));
|
||||||
const clearResizeSettleTimer = () => {
|
const clearResizeSettleTimer = () => {
|
||||||
if (resizeSettleTimerRef.current === null) return;
|
if (resizeSettleTimerRef.current === null) return;
|
||||||
clearTimeout(resizeSettleTimerRef.current);
|
clearTimeout(resizeSettleTimerRef.current);
|
||||||
@@ -248,12 +251,12 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
const nextViewport = { width: entry.contentRect.width, height: entry.contentRect.height };
|
const nextViewport = { width: entry.contentRect.width, height: entry.contentRect.height };
|
||||||
pendingViewportRef.current = nextViewport;
|
pendingViewportRef.current = nextViewport;
|
||||||
setViewport((currentViewport) =>
|
|
||||||
currentViewport.width === 0 || currentViewport.height === 0
|
|
||||||
? nextViewport
|
|
||||||
: currentViewport,
|
|
||||||
);
|
|
||||||
if (resizePointerActiveRef.current) return;
|
if (resizePointerActiveRef.current) return;
|
||||||
|
setViewport((currentViewport) =>
|
||||||
|
currentViewport.width > 0 && currentViewport.height > 0
|
||||||
|
? currentViewport
|
||||||
|
: retainDiffViewport(currentViewport, nextViewport),
|
||||||
|
);
|
||||||
clearResizeSettleTimer();
|
clearResizeSettleTimer();
|
||||||
resizeSettleTimerRef.current = setTimeout(() => {
|
resizeSettleTimerRef.current = setTimeout(() => {
|
||||||
resizeSettleTimerRef.current = null;
|
resizeSettleTimerRef.current = null;
|
||||||
@@ -403,11 +406,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (selectionRef.current) {
|
const dismissSelectionOnClick = selectionRef.current !== null;
|
||||||
|
if (dismissSelectionOnClick) {
|
||||||
selectionRef.current = null;
|
selectionRef.current = null;
|
||||||
dragRef.current = null;
|
|
||||||
schedulePaint();
|
schedulePaint();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const hit = pointHit(event);
|
const hit = pointHit(event);
|
||||||
if (hit?.kind !== "cell") return;
|
if (hit?.kind !== "cell") return;
|
||||||
@@ -416,6 +418,7 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
startX: event.clientX,
|
startX: event.clientX,
|
||||||
startY: event.clientY,
|
startY: event.clientY,
|
||||||
moved: false,
|
moved: false,
|
||||||
|
dismissSelectionOnClick,
|
||||||
};
|
};
|
||||||
selectionRef.current = { anchor: hit.position, focus: hit.position };
|
selectionRef.current = { anchor: hit.position, focus: hit.position };
|
||||||
event.currentTarget.focus();
|
event.currentTarget.focus();
|
||||||
@@ -486,6 +489,11 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
alreadyDragging: drag.moved,
|
alreadyDragging: drag.moved,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
|
if (drag && !moved && drag.dismissSelectionOnClick) {
|
||||||
|
selectionRef.current = null;
|
||||||
|
schedulePaint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
|
if (drag && !moved && hit?.kind === "cell" && hit.target && reviewActions) {
|
||||||
selectionRef.current = null;
|
selectionRef.current = null;
|
||||||
reviewActions.onStartComment(hit.target);
|
reviewActions.onStartComment(hit.target);
|
||||||
@@ -552,11 +560,10 @@ export function DiffSurface(props: DiffSurfaceProps) {
|
|||||||
>
|
>
|
||||||
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
<div style={contentStyle} onMouseDown={preventDocumentMouseSelection}>
|
||||||
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
<canvas ref={canvasRef} data-testid="git-diff-canvas" style={canvasStyle} />
|
||||||
{model.files.map((file, index) => (
|
{model.files.map((file) => (
|
||||||
<WebFileHeaderSection key={file.path} file={file}>
|
<WebFileHeaderSection key={file.path} file={file}>
|
||||||
<DocumentFileHeader
|
<DocumentFileHeader
|
||||||
file={file}
|
file={file}
|
||||||
showTopBorder={index > 0 && !model.files[index - 1]?.isCollapsed}
|
|
||||||
selectedPath={props.selectedPath}
|
selectedPath={props.selectedPath}
|
||||||
mode={props.mode}
|
mode={props.mode}
|
||||||
onToggleFile={props.onToggleFile}
|
onToggleFile={props.onToggleFile}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
createCachedAsciiTextMetrics,
|
||||||
createFallbackAwareTextMeasurer,
|
createFallbackAwareTextMeasurer,
|
||||||
requiresNativeParagraph,
|
requiresNativeParagraph,
|
||||||
type PrimaryTextFace,
|
type PrimaryTextFace,
|
||||||
@@ -40,6 +41,39 @@ describe("native paragraph retention", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("cached ASCII text metrics", () => {
|
||||||
|
it("measures each distinct grapheme once across lines", () => {
|
||||||
|
const measured: string[] = [];
|
||||||
|
const metrics = createCachedAsciiTextMetrics({
|
||||||
|
glyphIds: (text) => Array.from(text, () => 1),
|
||||||
|
measure(text) {
|
||||||
|
measured.push(text);
|
||||||
|
return text.length * 10;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metrics.measureAdvances(["a", "b", "a"])).toEqual([10, 20, 30]);
|
||||||
|
expect(metrics.measureAdvances(["b", "a"])).toEqual([10, 20]);
|
||||||
|
expect(measured).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caches glyph coverage per distinct ASCII character", () => {
|
||||||
|
const checked: string[] = [];
|
||||||
|
const metrics = createCachedAsciiTextMetrics({
|
||||||
|
glyphIds(text) {
|
||||||
|
checked.push(text);
|
||||||
|
return [text === "?" ? 0 : 1];
|
||||||
|
},
|
||||||
|
measure: (text) => text.length * 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metrics.hasEveryGlyph("abba")).toBe(true);
|
||||||
|
expect(metrics.hasEveryGlyph("bad?")).toBe(false);
|
||||||
|
expect(metrics.hasEveryGlyph("cab")).toBe(true);
|
||||||
|
expect(checked).toEqual(["a", "b", "d", "?", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
|
function primaryFaceWithout(...missing: string[]): PrimaryTextFace {
|
||||||
return {
|
return {
|
||||||
glyphIds(text) {
|
glyphIds(text) {
|
||||||
|
|||||||
@@ -5,12 +5,47 @@ export interface PrimaryTextFace {
|
|||||||
measure(text: string): number;
|
measure(text: string): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedAsciiTextMetrics {
|
||||||
|
hasEveryGlyph(text: string): boolean;
|
||||||
|
measureAdvances(graphemes: readonly string[]): number[];
|
||||||
|
}
|
||||||
|
|
||||||
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
|
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
|
||||||
|
|
||||||
export function requiresNativeParagraph(text: string): boolean {
|
export function requiresNativeParagraph(text: string): boolean {
|
||||||
return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text);
|
return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
|
||||||
|
const glyphCoverage = new Map<string, boolean>();
|
||||||
|
const widths = new Map<string, number>();
|
||||||
|
return {
|
||||||
|
hasEveryGlyph(text) {
|
||||||
|
for (const character of text) {
|
||||||
|
let hasGlyph = glyphCoverage.get(character);
|
||||||
|
if (hasGlyph === undefined) {
|
||||||
|
hasGlyph = primary.glyphIds(character).every((glyph) => glyph !== 0);
|
||||||
|
glyphCoverage.set(character, hasGlyph);
|
||||||
|
}
|
||||||
|
if (!hasGlyph) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
measureAdvances(graphemes) {
|
||||||
|
let advance = 0;
|
||||||
|
return graphemes.map((grapheme) => {
|
||||||
|
let width = widths.get(grapheme);
|
||||||
|
if (width === undefined) {
|
||||||
|
width = primary.measure(grapheme);
|
||||||
|
widths.set(grapheme, width);
|
||||||
|
}
|
||||||
|
advance += width;
|
||||||
|
return advance;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFallbackAwareTextMeasurer(input: {
|
export function createFallbackAwareTextMeasurer(input: {
|
||||||
primary: PrimaryTextFace;
|
primary: PrimaryTextFace;
|
||||||
measureWithSystemFallback: (text: string) => number;
|
measureWithSystemFallback: (text: string) => number;
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
} from "@shopify/react-native-skia";
|
} from "@shopify/react-native-skia";
|
||||||
import { fragmentTextForRange } from "./model";
|
import { fragmentTextForRange } from "./model";
|
||||||
import { codeTextColor } from "./palette";
|
import { codeTextColor } from "./palette";
|
||||||
import { createFallbackAwareTextMeasurer, requiresNativeParagraph } from "./text-measurement";
|
import {
|
||||||
|
createCachedAsciiTextMetrics,
|
||||||
|
createFallbackAwareTextMeasurer,
|
||||||
|
requiresNativeParagraph,
|
||||||
|
type CachedAsciiTextMetrics,
|
||||||
|
} from "./text-measurement";
|
||||||
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
import type { DiffCell, DiffDocumentModel, DiffFragment, DiffPalette, TextMeasurer } from "./types";
|
||||||
|
|
||||||
const PARAGRAPH_WIDTH = 100_000;
|
const PARAGRAPH_WIDTH = 100_000;
|
||||||
@@ -19,6 +24,7 @@ export interface NativeTextLayout {
|
|||||||
|
|
||||||
export interface NativeTextLayoutStore {
|
export interface NativeTextLayoutStore {
|
||||||
font: SkFont;
|
font: SkFont;
|
||||||
|
asciiMetrics: CachedAsciiTextMetrics;
|
||||||
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
paragraphsByCell: WeakMap<DiffCell, Array<SkParagraph | null>>;
|
||||||
ownedParagraphs: Set<SkParagraph>;
|
ownedParagraphs: Set<SkParagraph>;
|
||||||
families: string[];
|
families: string[];
|
||||||
@@ -43,6 +49,10 @@ export function createNativeTextLayoutStore(input: {
|
|||||||
const font = primaryFont(families, input.fontSize);
|
const font = primaryFont(families, input.fontSize);
|
||||||
return {
|
return {
|
||||||
font,
|
font,
|
||||||
|
asciiMetrics: createCachedAsciiTextMetrics({
|
||||||
|
glyphIds: (text) => font.getGlyphIDs(text),
|
||||||
|
measure: (text) => font.getTextWidth(text),
|
||||||
|
}),
|
||||||
paragraphsByCell: new WeakMap(),
|
paragraphsByCell: new WeakMap(),
|
||||||
ownedParagraphs: new Set(),
|
ownedParagraphs: new Set(),
|
||||||
families,
|
families,
|
||||||
@@ -63,7 +73,7 @@ export function prepareNativeTextLayout(
|
|||||||
const cached = store.paragraphsByCell.get(cell);
|
const cached = store.paragraphsByCell.get(cell);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const cellParagraphs = cell.fragments.map((fragment) => {
|
const cellParagraphs = cell.fragments.map((fragment) => {
|
||||||
if (!requiresRetainedParagraph(fragment.text, store.font)) return null;
|
if (!requiresRetainedParagraph(fragment.text, store.asciiMetrics)) return null;
|
||||||
const paragraph = createFragmentParagraph({
|
const paragraph = createFragmentParagraph({
|
||||||
cell,
|
cell,
|
||||||
fragment,
|
fragment,
|
||||||
@@ -88,11 +98,13 @@ export function createNativeTextMeasurer(input: {
|
|||||||
}): TextMeasurer {
|
}): TextMeasurer {
|
||||||
const families = nativeFontFamilies(input.configuredFamily);
|
const families = nativeFontFamilies(input.configuredFamily);
|
||||||
const font = primaryFont(families, input.fontSize);
|
const font = primaryFont(families, input.fontSize);
|
||||||
|
const primary = {
|
||||||
|
glyphIds: (text: string) => font.getGlyphIDs(text),
|
||||||
|
measure: (text: string) => font.getTextWidth(text),
|
||||||
|
};
|
||||||
|
const asciiMetrics = createCachedAsciiTextMetrics(primary);
|
||||||
const fallbackAware = createFallbackAwareTextMeasurer({
|
const fallbackAware = createFallbackAwareTextMeasurer({
|
||||||
primary: {
|
primary,
|
||||||
glyphIds: (text) => font.getGlyphIDs(text),
|
|
||||||
measure: (text) => font.getTextWidth(text),
|
|
||||||
},
|
|
||||||
measureWithSystemFallback(text) {
|
measureWithSystemFallback(text) {
|
||||||
const paragraph = createParagraph({
|
const paragraph = createParagraph({
|
||||||
text,
|
text,
|
||||||
@@ -110,13 +122,8 @@ export function createNativeTextMeasurer(input: {
|
|||||||
...fallbackAware,
|
...fallbackAware,
|
||||||
measureAdvances(graphemes) {
|
measureAdvances(graphemes) {
|
||||||
const text = graphemes.join("");
|
const text = graphemes.join("");
|
||||||
if (!requiresRetainedParagraph(text, font)) {
|
if (!requiresRetainedParagraph(text, asciiMetrics))
|
||||||
let prefix = "";
|
return asciiMetrics.measureAdvances(graphemes);
|
||||||
return graphemes.map((grapheme) => {
|
|
||||||
prefix += grapheme;
|
|
||||||
return font.getTextWidth(prefix);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const paragraph = createParagraph({
|
const paragraph = createParagraph({
|
||||||
text,
|
text,
|
||||||
families,
|
families,
|
||||||
@@ -139,8 +146,8 @@ export function createNativeTextMeasurer(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function requiresRetainedParagraph(text: string, font: SkFont): boolean {
|
function requiresRetainedParagraph(text: string, asciiMetrics: CachedAsciiTextMetrics): boolean {
|
||||||
return requiresNativeParagraph(text) || font.getGlyphIDs(text).some((glyph) => glyph === 0);
|
return requiresNativeParagraph(text) || !asciiMetrics.hasEveryGlyph(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFragmentParagraph(input: {
|
function createFragmentParagraph(input: {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { retainDiffViewport } from "./viewport";
|
||||||
|
|
||||||
|
describe("retainDiffViewport", () => {
|
||||||
|
it("waits for the first positive layout", () => {
|
||||||
|
const initial = { width: 0, height: 0 };
|
||||||
|
|
||||||
|
expect(retainDiffViewport(initial, { width: 0, height: 700 })).toBe(initial);
|
||||||
|
expect(retainDiffViewport(initial, { width: 390, height: 0 })).toBe(initial);
|
||||||
|
expect(retainDiffViewport(initial, { width: 390, height: 700 })).toEqual({
|
||||||
|
width: 390,
|
||||||
|
height: 700,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the completed viewport while a retained panel is hidden", () => {
|
||||||
|
const visible = { width: 390, height: 700 };
|
||||||
|
|
||||||
|
expect(retainDiffViewport(visible, { width: 0, height: 0 })).toBe(visible);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a real resize and preserves identity for duplicate layouts", () => {
|
||||||
|
const current = { width: 390, height: 700 };
|
||||||
|
|
||||||
|
expect(retainDiffViewport(current, current)).toBe(current);
|
||||||
|
expect(retainDiffViewport(current, { width: 700, height: 390 })).toEqual({
|
||||||
|
width: 700,
|
||||||
|
height: 390,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface DiffViewport {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retainDiffViewport(current: DiffViewport, next: DiffViewport): DiffViewport {
|
||||||
|
if (next.width <= 0 || next.height <= 0) return current;
|
||||||
|
if (current.width === next.width && current.height === next.height) return current;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user