Files
Genarrative/src/components/image-editor/useImageCanvasViewportControls.ts
T
k88936 60a4548524
Project CI / Repository checks (push) Successful in 1m30s
Project CI / Frontend tests (push) Successful in 2m28s
Project CI / Native shell tests (push) Successful in 2m49s
Project CI / Backend tests (push) Successful in 3m40s
fix: 输入框内滚动会触发画布滚动 (#110)
fix:  ignore wheel event from text area to prevent canva move
before:
![shotmd-1784861686-compressed.webp](/attachments/916a0d1c-2ab0-4a16-ae35-0ee599ee9df2)
after:
![shotmd-1784861713-compressed.webp](/attachments/f0558ff5-55d6-471f-b048-18b623dfb911)

Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/110
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-24 20:47:05 +08:00

336 lines
9.0 KiB
TypeScript

import {
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
DEFAULT_CANVAS_SIZE,
} from './ImageCanvasEditorModel';
import type {
CanvasLayer,
CanvasViewport,
DragState,
} from './ImageCanvasEditorTypes';
import {
createMinimapModel,
fitViewportToLayers,
getCanvasDropPoint as resolveCanvasDropPoint,
getCanvasPointFromClient as resolveCanvasPointFromClient,
getWorldPointFromClient,
moveViewportFromMinimapDrag as resolveViewportFromMinimapDrag,
moveViewportFromMinimapPointer as resolveViewportFromMinimapPointer,
panViewportFromWheel,
scaleViewportFromScreenPoint,
zoomViewportFromWheel,
} from './ImageCanvasInteractionModel';
export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = {
x: -260,
y: 70,
scale: 0.5,
};
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, setViewport] = useState<CanvasViewport>(
DEFAULT_IMAGE_CANVAS_VIEWPORT,
);
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 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],
);
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],
);
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 minimapElement = document.querySelector(
'.image-canvas-editor__minimap',
) as HTMLElement | 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, minimapModel],
);
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,
}),
);
}, []);
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,
}),
);
});
},
[],
);
useEffect(
() => () => {
if (minimapDragFrameRef.current !== null) {
window.cancelAnimationFrame(minimapDragFrameRef.current);
minimapDragFrameRef.current = null;
}
pendingMinimapDragRef.current = null;
},
[],
);
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();
if (!event.ctrlKey && !event.metaKey) {
const useShiftWheelHorizontalFallback =
event.shiftKey && event.deltaX === 0;
setViewport((currentViewport) =>
panViewportFromWheel({
viewport: currentViewport,
deltaX: useShiftWheelHorizontalFallback
? event.deltaY
: event.deltaX,
deltaY: useShiftWheelHorizontalFallback ? 0 : event.deltaY,
}),
);
return;
}
const rect = viewportElement.getBoundingClientRect();
const screenPoint = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
setViewport((currentViewport) =>
zoomViewportFromWheel({
viewport: currentViewport,
deltaY: event.deltaY,
screenPoint,
}),
);
},
[canvasViewportRef],
);
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,
canvasSize,
minimapModel,
updateScaleFromCenter,
fitLayers,
resolveCanvasPoint,
getCanvasDropPoint,
getCanvasPointFromClient,
moveViewportFromMinimapPointer,
updateViewportFromMinimapDrag,
flushMinimapViewportDrag,
};
}