Files
Genarrative/packages/image-canvas-react/src/useImageCanvasViewportControls.ts
T
menghao 03a4410272
Project CI / Native shell tests (push) Failing after 11m56s
Project CI / Repository checks (push) Successful in 1m5s
Project CI / Frontend tests (push) Successful in 3m20s
Project CI / Backend tests (push) Successful in 4m1s
game agent无限画布开发 (#136)
主要工作是共享同一套“画布内核与通用 UI 源码”,网站和 Tauri 只分别实现宿主适配层。

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/136
Co-authored-by: menghao <mh18530625731@163.com>
Co-committed-by: menghao <mh18530625731@163.com>
2026-08-12 10:54:16 +08:00

421 lines
12 KiB
TypeScript

import type {
CanvasLayer,
CanvasViewport,
DragState,
} from '@genarrative/image-canvas-core';
import {
createMinimapModel,
DEFAULT_CANVAS_SIZE,
fitViewportToLayers,
getCanvasDropPoint as resolveCanvasDropPoint,
getCanvasPointFromClient as resolveCanvasPointFromClient,
getWorldPointFromClient,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
resolveViewportFromWheel,
scaleViewportFromScreenPoint,
} from '@genarrative/image-canvas-core';
import {
type Dispatch,
type RefObject,
type SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = {
x: -260,
y: 70,
scale: 0.5,
};
export const IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS = 420;
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}
function interpolateViewport(
start: CanvasViewport,
target: CanvasViewport,
progress: number,
): CanvasViewport {
return {
x: start.x + (target.x - start.x) * progress,
y: start.y + (target.y - start.y) * progress,
scale: start.scale + (target.scale - start.scale) * progress,
};
}
function prefersReducedCanvasMotion() {
return (
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
}
function isCanvasWheelInteractionTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest('input, textarea, select, [contenteditable="true"]') !== null
);
}
type UseImageCanvasViewportControlsOptions = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
layers: CanvasLayer[];
captureCanvasHistory: () => void;
};
export function useImageCanvasViewportControls({
canvasViewportRef,
layers,
captureCanvasHistory,
}: UseImageCanvasViewportControlsOptions) {
const [viewport, setViewportState] = useState<CanvasViewport>(
DEFAULT_IMAGE_CANVAS_VIEWPORT,
);
const viewportRef = useRef(viewport);
const viewportAnimationFrameRef = useRef<number | null>(null);
const [canvasSize, setCanvasSize] = useState(DEFAULT_CANVAS_SIZE);
const pendingMinimapDragRef = useRef<{
dragState: Extract<DragState, { kind: 'minimap' }>;
clientX: number;
clientY: number;
} | null>(null);
const minimapDragFrameRef = useRef<number | null>(null);
const cancelViewportAnimation = useCallback(() => {
if (viewportAnimationFrameRef.current === null) {
return;
}
window.cancelAnimationFrame(viewportAnimationFrameRef.current);
viewportAnimationFrameRef.current = null;
}, []);
const setViewport = useCallback<Dispatch<SetStateAction<CanvasViewport>>>(
(nextViewport) => {
cancelViewportAnimation();
const resolvedViewport =
typeof nextViewport === 'function'
? nextViewport(viewportRef.current)
: nextViewport;
viewportRef.current = resolvedViewport;
setViewportState(resolvedViewport);
},
[cancelViewportAnimation],
);
const animateViewportTo = useCallback(
(targetViewport: CanvasViewport) => {
cancelViewportAnimation();
if (prefersReducedCanvasMotion()) {
viewportRef.current = targetViewport;
setViewportState(targetViewport);
return;
}
const startViewport = viewportRef.current;
let startedAt: number | null = null;
const step = (timestamp: number) => {
startedAt ??= timestamp;
const elapsedMs = Math.max(0, timestamp - startedAt);
const progress = Math.min(
1,
elapsedMs / IMAGE_CANVAS_VIEWPORT_ANIMATION_DURATION_MS,
);
const nextViewport = interpolateViewport(
startViewport,
targetViewport,
easeOutCubic(progress),
);
viewportRef.current = nextViewport;
setViewportState(nextViewport);
if (progress >= 1) {
viewportAnimationFrameRef.current = null;
return;
}
viewportAnimationFrameRef.current = window.requestAnimationFrame(step);
};
viewportAnimationFrameRef.current = window.requestAnimationFrame(step);
},
[cancelViewportAnimation],
);
const minimapModel = useMemo(
() => createMinimapModel({ layers, viewport, canvasSize }),
[canvasSize, layers, viewport],
);
useEffect(() => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return undefined;
}
const updateCanvasSize = () => {
setCanvasSize({
width: viewportElement.clientWidth || DEFAULT_CANVAS_SIZE.width,
height: viewportElement.clientHeight || DEFAULT_CANVAS_SIZE.height,
});
};
updateCanvasSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateCanvasSize);
return () => window.removeEventListener('resize', updateCanvasSize);
}
const observer = new ResizeObserver(updateCanvasSize);
observer.observe(viewportElement);
return () => observer.disconnect();
}, [canvasViewportRef]);
const updateScaleFromCenter = useCallback(
(nextScale: number) => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
captureCanvasHistory();
setViewport((currentViewport) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: null,
}),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const centerX = rect.width > 0 ? rect.width / 2 : canvasSize.width / 2;
const centerY = rect.height > 0 ? rect.height / 2 : canvasSize.height / 2;
captureCanvasHistory();
setViewport((currentViewport) =>
scaleViewportFromScreenPoint({
viewport: currentViewport,
nextScale,
screenPoint: { x: centerX, y: centerY },
}),
);
},
[
canvasSize.height,
canvasSize.width,
canvasViewportRef,
captureCanvasHistory,
setViewport,
],
);
const fitLayers = useCallback(
(
targetLayers: CanvasLayer[] = layers,
options: { captureHistory?: boolean } = {},
) => {
const nextViewport = fitViewportToLayers({
layers: targetLayers,
canvasSize,
});
if (!nextViewport) {
return;
}
if (options.captureHistory !== false) {
captureCanvasHistory();
}
setViewport(nextViewport);
},
[captureCanvasHistory, canvasSize, layers, setViewport],
);
const resolveCanvasPoint = useCallback(
(clientX: number, clientY: number) => {
const rect = canvasViewportRef.current?.getBoundingClientRect() ?? null;
return resolveCanvasPointFromClient({ clientX, clientY, rect });
},
[canvasViewportRef],
);
const getCanvasDropPoint = useCallback(
(clientX: number, clientY: number) =>
resolveCanvasDropPoint({
clientX,
clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
canvasSize,
}),
[canvasSize, canvasViewportRef],
);
const getCanvasPointFromClient = useCallback(
(clientX: number, clientY: number) =>
getWorldPointFromClient({
clientX,
clientY,
rect: canvasViewportRef.current?.getBoundingClientRect() ?? null,
viewport,
}),
[canvasViewportRef, viewport],
);
const moveViewportFromMinimapPointer = useCallback(
(clientX: number, clientY: number) => {
if (!minimapModel) {
return;
}
const localMinimap = canvasViewportRef.current?.querySelector(
'.genarrative-image-canvas__minimap',
) as HTMLElement | null;
const documentMinimaps = document.querySelectorAll<HTMLElement>(
'.genarrative-image-canvas__minimap',
);
const minimapElement =
localMinimap ??
(documentMinimaps.length === 1 ? documentMinimaps.item(0) : null);
const rect = minimapElement?.getBoundingClientRect();
if (!rect) {
return;
}
setViewport((currentViewport) =>
resolveViewportFromMinimapPointer({
viewport: currentViewport,
canvasSize,
minimapModel,
pointer: {
x: clientX - rect.left,
y: clientY - rect.top,
},
}),
);
},
[canvasSize, canvasViewportRef, minimapModel, setViewport],
);
const flushMinimapViewportDrag = useCallback(() => {
if (minimapDragFrameRef.current !== null) {
window.cancelAnimationFrame(minimapDragFrameRef.current);
minimapDragFrameRef.current = null;
}
const pendingDrag = pendingMinimapDragRef.current;
pendingMinimapDragRef.current = null;
if (!pendingDrag) {
return;
}
setViewport(
resolveViewportFromMinimapDrag(pendingDrag.dragState, {
x: pendingDrag.clientX,
y: pendingDrag.clientY,
}),
);
}, [setViewport]);
const updateViewportFromMinimapDrag = useCallback(
(
dragState: Extract<DragState, { kind: 'minimap' }>,
clientX: number,
clientY: number,
) => {
pendingMinimapDragRef.current = { dragState, clientX, clientY };
if (minimapDragFrameRef.current !== null) {
return;
}
minimapDragFrameRef.current = window.requestAnimationFrame(() => {
minimapDragFrameRef.current = null;
const pendingDrag = pendingMinimapDragRef.current;
pendingMinimapDragRef.current = null;
if (!pendingDrag) {
return;
}
setViewport(
resolveViewportFromMinimapDrag(pendingDrag.dragState, {
x: pendingDrag.clientX,
y: pendingDrag.clientY,
}),
);
});
},
[setViewport],
);
useEffect(
() => () => {
cancelViewportAnimation();
if (minimapDragFrameRef.current !== null) {
window.cancelAnimationFrame(minimapDragFrameRef.current);
minimapDragFrameRef.current = null;
}
pendingMinimapDragRef.current = null;
},
[cancelViewportAnimation],
);
const handleNativeWheel = useCallback(
(event: WheelEvent) => {
// Let form controls and their scrollable panels keep native wheel input.
if (isCanvasWheelInteractionTarget(event.target)) {
return;
}
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return;
}
event.preventDefault();
const rect = viewportElement.getBoundingClientRect();
const screenPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setViewport(
(currentViewport) =>
resolveViewportFromWheel({
viewport: currentViewport,
deltaX: event.deltaX,
deltaY: event.deltaY,
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
screenPoint,
}).viewport,
);
},
[canvasViewportRef, setViewport],
);
useEffect(() => {
const viewportElement = canvasViewportRef.current;
if (!viewportElement) {
return undefined;
}
viewportElement.addEventListener('wheel', handleNativeWheel, {
passive: false,
});
return () => {
viewportElement.removeEventListener('wheel', handleNativeWheel);
};
}, [canvasViewportRef, handleNativeWheel]);
return {
viewport,
setViewport,
animateViewportTo,
canvasSize,
minimapModel,
updateScaleFromCenter,
fitLayers,
resolveCanvasPoint,
getCanvasDropPoint,
getCanvasPointFromClient,
moveViewportFromMinimapPointer,
updateViewportFromMinimapDrag,
flushMinimapViewportDrag,
};
}