refactor(app): implement canvas-based diff rendering
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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;
|
||||
}
|
||||
|
||||
interface PressPointEvent {
|
||||
nativeEvent: { pageX: number; pageY: number };
|
||||
}
|
||||
|
||||
interface PressOrigin {
|
||||
startedAt: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
interface StickyPressReleaseInput {
|
||||
enabled: boolean;
|
||||
native: boolean;
|
||||
pressHandled: boolean;
|
||||
layoutY: number | null;
|
||||
pressOrigin: PressOrigin | null;
|
||||
releasedAt: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
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") {
|
||||
return event.stickyFallbackEligible && phase === "pressed"
|
||||
? { phase: "released", effect: "defer-activate" }
|
||||
: { phase: phase === "long" ? "long" : "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.layoutY !== 0 ||
|
||||
!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 } = input;
|
||||
const layoutYRef = useRef<number | null>(null);
|
||||
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) => {
|
||||
layoutYRef.current = event.nativeEvent.layout.y;
|
||||
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,
|
||||
layoutY: layoutYRef.current,
|
||||
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],
|
||||
);
|
||||
|
||||
return { activate, select, onLayout, onPressIn, onPressOut, onLongPress };
|
||||
}
|
||||
Reference in New Issue
Block a user