03a4410272
主要工作是共享同一套“画布内核与通用 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>
421 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|