Files
paseo/packages/app/src/git/file-header-interaction.ts
T
Mohamed Boudra c7b103a2cd fix(app): stabilize canvas diff rendering
Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets.

Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
2026-08-17 21:14:38 +02:00

199 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
import type { LayoutChangeEvent } from "react-native";
import { isNative } from "@/constants/platform";
const STICKY_PRESS_MAX_DURATION_MS = 500;
const STICKY_PRESS_MAX_DISTANCE = 12;
interface FileHeaderInteractionInput {
path: string;
enabled: boolean;
onSelect?: (path: string) => void;
onActivate?: (path: string) => void;
onLayout?: (height: number) => void;
stickyPressFallback?: boolean;
}
interface PressPointEvent {
nativeEvent: { pageX: number; pageY: number };
}
interface PressOrigin {
startedAt: number;
pageX: number;
pageY: number;
}
interface StickyPressReleaseInput {
enabled: boolean;
native: boolean;
pressHandled: boolean;
stickyPressFallback: boolean;
pressOrigin: PressOrigin | null;
releasedAt: number;
pageX: number;
pageY: number;
}
export interface FileHeaderInteraction {
activate: () => void;
select: () => void;
onLayout: (event: LayoutChangeEvent) => void;
onPressIn: (event: PressPointEvent) => void;
onPressOut: (event: PressPointEvent) => void;
onLongPress: () => void;
}
export type FileHeaderPressPhase = "idle" | "pressed" | "released" | "handled" | "long";
export type FileHeaderPressEvent =
| { type: "press-in" }
| { type: "press-out"; stickyFallbackEligible: boolean }
| { type: "press" }
| { type: "long-press" }
| { type: "fallback" };
export type FileHeaderPressEffect = "none" | "select" | "activate" | "defer-activate";
export function reduceFileHeaderPress(
phase: FileHeaderPressPhase,
event: FileHeaderPressEvent,
): { phase: FileHeaderPressPhase; effect: FileHeaderPressEffect } {
if (event.type === "press-in") return { phase: "pressed", effect: "none" };
if (event.type === "long-press") return { phase: "long", effect: "select" };
if (event.type === "press-out") {
if (phase === "released" || phase === "handled" || phase === "long") {
return { phase, effect: "none" };
}
return event.stickyFallbackEligible && phase === "pressed"
? { phase: "released", effect: "defer-activate" }
: { phase: "idle", effect: "none" };
}
if (event.type === "press") {
return phase === "long" || phase === "handled"
? { phase: "handled", effect: "none" }
: { phase: "handled", effect: "activate" };
}
return phase === "released"
? { phase: "handled", effect: "activate" }
: { phase, effect: "none" };
}
export function shouldActivateStickyHeaderPress(input: StickyPressReleaseInput): boolean {
if (
!input.enabled ||
!input.native ||
input.pressHandled ||
!input.stickyPressFallback ||
!input.pressOrigin
) {
return false;
}
const durationMs = input.releasedAt - input.pressOrigin.startedAt;
const dx = input.pageX - input.pressOrigin.pageX;
const dy = input.pageY - input.pressOrigin.pageY;
return (
durationMs <= STICKY_PRESS_MAX_DURATION_MS && Math.hypot(dx, dy) <= STICKY_PRESS_MAX_DISTANCE
);
}
export function useFileHeaderInteraction(input: FileHeaderInteractionInput): FileHeaderInteraction {
const {
path,
enabled,
onSelect,
onActivate,
onLayout: notifyLayout,
stickyPressFallback = false,
} = input;
const pressHandledRef = useRef(false);
const pressOriginRef = useRef<PressOrigin | null>(null);
const phaseRef = useRef<FileHeaderPressPhase>("idle");
const fallbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearFallback = useCallback(() => {
if (fallbackTimerRef.current !== null) clearTimeout(fallbackTimerRef.current);
fallbackTimerRef.current = null;
}, []);
useEffect(() => clearFallback, [clearFallback]);
const select = useCallback(() => {
if (enabled) onSelect?.(path);
}, [enabled, onSelect, path]);
const activate = useCallback(() => {
if (!enabled) return;
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "press" });
phaseRef.current = transition.phase;
if (transition.effect !== "activate") return;
pressHandledRef.current = true;
select();
onActivate?.(path);
}, [clearFallback, enabled, onActivate, path, select]);
const onLayout = useCallback(
(event: LayoutChangeEvent) => {
notifyLayout?.(event.nativeEvent.layout.height);
},
[notifyLayout],
);
const onPressIn = useCallback(
(event: PressPointEvent) => {
clearFallback();
phaseRef.current = reduceFileHeaderPress(phaseRef.current, { type: "press-in" }).phase;
pressHandledRef.current = false;
pressOriginRef.current = {
startedAt: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
};
},
[clearFallback],
);
const onLongPress = useCallback(() => {
clearFallback();
const transition = reduceFileHeaderPress(phaseRef.current, { type: "long-press" });
phaseRef.current = transition.phase;
pressHandledRef.current = true;
if (transition.effect === "select") select();
}, [clearFallback, select]);
const onPressOut = useCallback(
(event: PressPointEvent) => {
const eligible = shouldActivateStickyHeaderPress({
enabled,
native: isNative,
pressHandled: pressHandledRef.current,
stickyPressFallback,
pressOrigin: pressOriginRef.current,
releasedAt: Date.now(),
pageX: event.nativeEvent.pageX,
pageY: event.nativeEvent.pageY,
});
const transition = reduceFileHeaderPress(phaseRef.current, {
type: "press-out",
stickyFallbackEligible: eligible,
});
phaseRef.current = transition.phase;
if (transition.effect === "defer-activate") {
clearFallback();
fallbackTimerRef.current = setTimeout(() => {
fallbackTimerRef.current = null;
const fallback = reduceFileHeaderPress(phaseRef.current, { type: "fallback" });
phaseRef.current = fallback.phase;
if (fallback.effect === "activate") {
pressHandledRef.current = true;
select();
onActivate?.(path);
}
}, 0);
}
},
[clearFallback, enabled, onActivate, path, select, stickyPressFallback],
);
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
}