合并master并保留Spine序列帧修复

合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
2026-08-14 21:33:26 +08:00
338 changed files with 69729 additions and 8828 deletions
@@ -0,0 +1,101 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import {
CanvasChromeButton,
CanvasToolbar,
CanvasToolbarDivider,
CanvasToolbarGroup,
} from './CanvasChrome';
describe('shared canvas chrome', () => {
it('forwards native button props and exposes shared accessible states', () => {
const onClick = vi.fn();
const ref = createRef<HTMLButtonElement>();
render(
<CanvasChromeButton
ref={ref}
label="选择工具"
title="选择"
icon={<span data-testid="tool-icon" />}
className="host-button"
data-host="main-site"
pressed
expanded={false}
onClick={onClick}
/>,
);
const button = screen.getByRole('button', { name: '选择工具' });
expect(button.className).toContain(
'genarrative-image-canvas__chrome-button',
);
expect(button.className).toContain('host-button');
expect(button.getAttribute('type')).toBe('button');
expect(button.getAttribute('title')).toBe('选择');
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(button.getAttribute('data-host')).toBe('main-site');
expect(ref.current).toBe(button);
fireEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it('supports a short visible label and native disabled behavior', () => {
const onClick = vi.fn();
render(
<CanvasChromeButton
label="改造素材"
icon={<span />}
disabled
onClick={onClick}
>
</CanvasChromeButton>,
);
const button = screen.getByRole('button', { name: '改造素材' });
expect(button.className).toContain(
'genarrative-image-canvas__chrome-button--with-label',
);
expect(button.getAttribute('title')).toBe('改造素材');
expect(button).toHaveProperty('disabled', true);
fireEvent.click(button);
expect(onClick).not.toHaveBeenCalled();
});
it('provides a named toolbar with groups and decorative dividers', () => {
render(
<CanvasToolbar
label="素材工具栏"
surface="floating"
className="host-toolbar"
>
<CanvasToolbarGroup data-testid="toolbar-group">
<CanvasChromeButton label="下载" icon={<span />} />
</CanvasToolbarGroup>
<CanvasToolbarDivider data-testid="toolbar-divider" />
</CanvasToolbar>,
);
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
expect(toolbar.className).toContain(
'genarrative-image-canvas__toolbar--floating',
);
expect(toolbar.className).toContain('host-toolbar');
expect(toolbar.getAttribute('aria-orientation')).toBe('horizontal');
expect(screen.getByTestId('toolbar-group').className).toContain(
'genarrative-image-canvas__toolbar-group',
);
expect(
screen.getByTestId('toolbar-divider').getAttribute('aria-hidden'),
).toBe('true');
});
});
@@ -0,0 +1,152 @@
import {
type ButtonHTMLAttributes,
forwardRef,
type HTMLAttributes,
type ReactNode,
} from 'react';
function joinClassNames(...classNames: Array<string | undefined | false>) {
return classNames.filter(Boolean).join(' ');
}
export type CanvasChromeButtonProps = Omit<
ButtonHTMLAttributes<HTMLButtonElement>,
'aria-expanded' | 'aria-label' | 'aria-pressed' | 'children'
> & {
label: string;
icon: ReactNode;
children?: ReactNode;
pressed?: boolean;
expanded?: boolean;
};
/**
* 画布宿主共享的动作按钮,只承接 chrome、可访问性与原生按钮状态。
*/
export const CanvasChromeButton = forwardRef<
HTMLButtonElement,
CanvasChromeButtonProps
>(function CanvasChromeButton(
{
label,
icon,
children,
pressed,
expanded,
title = label,
type = 'button',
className,
...buttonProps
},
ref,
) {
const hasVisibleLabel =
children !== undefined && children !== null && children !== false;
return (
<button
{...buttonProps}
ref={ref}
type={type}
className={joinClassNames(
'genarrative-image-canvas__chrome-button',
hasVisibleLabel &&
'genarrative-image-canvas__chrome-button--with-label',
className,
)}
aria-label={label}
aria-pressed={pressed}
aria-expanded={expanded}
title={title}
>
<span className="genarrative-image-canvas__chrome-button-icon">
{icon}
</span>
{hasVisibleLabel ? (
<span className="genarrative-image-canvas__chrome-button-label">
{children}
</span>
) : null}
</button>
);
});
export type CanvasToolbarProps = Omit<
HTMLAttributes<HTMLDivElement>,
'aria-label' | 'children' | 'role'
> & {
label: string;
children: ReactNode;
surface?: 'plain' | 'floating';
};
/** 画布宿主共享的横向工具栏容器。 */
export const CanvasToolbar = forwardRef<HTMLDivElement, CanvasToolbarProps>(
function CanvasToolbar(
{ label, children, surface = 'plain', className, ...toolbarProps },
ref,
) {
return (
<div
{...toolbarProps}
ref={ref}
role="toolbar"
aria-label={label}
aria-orientation="horizontal"
className={joinClassNames(
'genarrative-image-canvas__toolbar',
`genarrative-image-canvas__toolbar--${surface}`,
className,
)}
>
{children}
</div>
);
},
);
export type CanvasToolbarGroupProps = HTMLAttributes<HTMLSpanElement> & {
children: ReactNode;
};
/** 将同类画布动作保持为不可拆散的横向分组。 */
export const CanvasToolbarGroup = forwardRef<
HTMLSpanElement,
CanvasToolbarGroupProps
>(function CanvasToolbarGroup({ children, className, ...groupProps }, ref) {
return (
<span
{...groupProps}
ref={ref}
className={joinClassNames(
'genarrative-image-canvas__toolbar-group',
className,
)}
>
{children}
</span>
);
});
export type CanvasToolbarDividerProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'aria-hidden' | 'children'
>;
/** 纯视觉工具分隔符,不进入无障碍树。 */
export const CanvasToolbarDivider = forwardRef<
HTMLSpanElement,
CanvasToolbarDividerProps
>(function CanvasToolbarDivider({ className, ...dividerProps }, ref) {
return (
<span
{...dividerProps}
ref={ref}
aria-hidden="true"
className={joinClassNames(
'genarrative-image-canvas__toolbar-divider',
className,
)}
/>
);
});
@@ -0,0 +1,41 @@
import { createContext, type ReactNode, useContext } from 'react';
import { createPortal } from 'react-dom';
const CanvasPortalRootContext = createContext<HTMLElement | null>(null);
export function CanvasPortalProvider({
children,
root,
}: {
children: ReactNode;
root: HTMLElement | null;
}) {
return (
<CanvasPortalRootContext.Provider value={root}>
{children}
</CanvasPortalRootContext.Provider>
);
}
export function CanvasPortal({
children,
className = '',
container,
}: {
children: ReactNode;
className?: string;
container?: HTMLElement | null;
}) {
const contextualRoot = useContext(CanvasPortalRootContext);
const portalRoot =
container ??
contextualRoot ??
(typeof document === 'undefined' ? null : document.body);
if (!portalRoot) return <>{children}</>;
return createPortal(
<div className={`genarrative-image-canvas__portal ${className}`.trim()}>
{children}
</div>,
portalRoot,
);
}
@@ -0,0 +1,50 @@
import type { CanvasTool } from '@genarrative/image-canvas-core';
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react';
export type CanvasViewportProps = HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
backgroundColor?: string;
isInteractionPaused?: boolean;
isPanning?: boolean;
tool?: CanvasTool;
};
export const CanvasViewport = forwardRef<HTMLDivElement, CanvasViewportProps>(
function CanvasViewport(
{
children,
backgroundColor,
className = '',
isInteractionPaused = false,
isPanning = false,
style,
tool = 'select',
...props
},
ref,
) {
return (
<div
{...props}
ref={ref}
className={[
'genarrative-image-canvas',
'genarrative-image-canvas__viewport',
isPanning ? 'genarrative-image-canvas__viewport--panning' : '',
`genarrative-image-canvas__viewport--tool-${tool}`,
className,
]
.filter(Boolean)
.join(' ')}
style={{
...style,
backgroundColor,
pointerEvents: isInteractionPaused ? 'none' : style?.pointerEvents,
}}
aria-disabled={isInteractionPaused || undefined}
>
{children}
</div>
);
},
);
@@ -0,0 +1,33 @@
import {
CANVAS_WORLD_SIZE,
type CanvasViewport,
} from '@genarrative/image-canvas-core';
import type { HTMLAttributes, ReactNode } from 'react';
export function CanvasWorld({
children,
className = '',
viewport,
worldSize = CANVAS_WORLD_SIZE,
style,
...props
}: HTMLAttributes<HTMLDivElement> & {
children: ReactNode;
viewport: CanvasViewport;
worldSize?: number;
}) {
return (
<div
{...props}
className={`genarrative-image-canvas__world ${className}`.trim()}
style={{
...style,
width: worldSize,
height: worldSize,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,52 @@
import type { CanvasLayer } from '@genarrative/image-canvas-core';
import type { HTMLAttributes, KeyboardEventHandler, ReactNode } from 'react';
export function LayerRenderer({
as = 'button',
children,
className = '',
generating = false,
hovered = false,
layer,
selected = false,
style,
...props
}: Omit<HTMLAttributes<HTMLElement>, 'style'> & {
as?: 'button' | 'div';
children: ReactNode;
generating?: boolean;
hovered?: boolean;
layer: CanvasLayer;
selected?: boolean;
style?: HTMLAttributes<HTMLElement>['style'];
onKeyDown?: KeyboardEventHandler<HTMLElement>;
}) {
const sharedProps = {
...props,
className: [
'genarrative-image-canvas__layer',
selected ? 'genarrative-image-canvas__layer--selected' : '',
hovered ? 'genarrative-image-canvas__layer--hovered' : '',
generating ? 'genarrative-image-canvas__layer--generating' : '',
layer.locked ? 'genarrative-image-canvas__layer--locked' : '',
className,
]
.filter(Boolean)
.join(' '),
style: {
...style,
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : style?.display,
},
children,
};
return as === 'div' ? (
<div {...sharedProps} role={props.role ?? 'button'} />
) : (
<button {...sharedProps} type="button" />
);
}
@@ -0,0 +1,48 @@
import type { StageMinimapModel } from '@genarrative/image-canvas-core';
import type { PointerEventHandler, Ref } from 'react';
export function Minimap({
className = '',
layerClassName = '',
minimapRef,
model,
onPointerDown,
stageClassName = '',
viewportClassName = '',
}: {
className?: string;
layerClassName?: string;
minimapRef?: Ref<HTMLButtonElement>;
model: StageMinimapModel;
onPointerDown: PointerEventHandler<HTMLButtonElement>;
stageClassName?: string;
viewportClassName?: string;
}) {
return (
<button
ref={minimapRef}
type="button"
className={`genarrative-image-canvas__minimap ${className}`.trim()}
aria-label="画布小地图"
title="拖拽移动视图"
onPointerDown={onPointerDown}
>
<span
className={`genarrative-image-canvas__minimap-stage ${stageClassName}`.trim()}
>
{model.layers.map((layer) => (
<span
key={layer.id}
className={`genarrative-image-canvas__minimap-layer ${layerClassName}`.trim()}
title={layer.title}
style={layer.rect}
/>
))}
<span
className={`genarrative-image-canvas__minimap-viewport ${viewportClassName}`.trim()}
style={model.viewport}
/>
</span>
</button>
);
}
@@ -0,0 +1,32 @@
import type {
CanvasMarqueeState,
CanvasViewport,
} from '@genarrative/image-canvas-core';
export function SelectionOverlay({
className = '',
marquee,
viewport,
}: {
className?: string;
marquee: CanvasMarqueeState | null;
viewport: CanvasViewport;
}) {
if (!marquee) return null;
return (
<div
className={`genarrative-image-canvas__selection-overlay ${className}`.trim()}
aria-hidden="true"
style={{
left:
(Math.min(marquee.startX, marquee.currentX) - viewport.x) /
viewport.scale,
top:
(Math.min(marquee.startY, marquee.currentY) - viewport.y) /
viewport.scale,
width: Math.abs(marquee.currentX - marquee.startX) / viewport.scale,
height: Math.abs(marquee.currentY - marquee.startY) / viewport.scale,
}}
/>
);
}
@@ -0,0 +1,36 @@
import {
canvasDisplayScaleToViewportScale,
type CanvasViewport,
formatCanvasDisplayScalePercent,
} from '@genarrative/image-canvas-core';
import type { ReactNode } from 'react';
export type CanvasZoomActions = {
displayPercent: string;
fit: () => void;
zoomIn: () => void;
zoomOut: () => void;
zoomToDisplayScale: (displayScale: number) => void;
};
export function ZoomControls({
children,
onFit,
onScaleFromCenter,
viewport,
}: {
children: (actions: CanvasZoomActions) => ReactNode;
onFit: () => void;
onScaleFromCenter: (scale: number) => void;
viewport: CanvasViewport;
}) {
const actions: CanvasZoomActions = {
displayPercent: formatCanvasDisplayScalePercent(viewport.scale),
fit: onFit,
zoomIn: () => onScaleFromCenter(viewport.scale * 1.16),
zoomOut: () => onScaleFromCenter(viewport.scale * 0.86),
zoomToDisplayScale: (displayScale) =>
onScaleFromCenter(canvasDisplayScaleToViewportScale(displayScale)),
};
return <>{children(actions)}</>;
}
+12
View File
@@ -0,0 +1,12 @@
export * from './CanvasChrome';
export * from './CanvasPortal';
export * from './CanvasViewport';
export * from './CanvasWorld';
export * from './LayerRenderer';
export * from './Minimap';
export * from './SelectionOverlay';
export * from './useCanvasHistory';
export * from './useImageCanvasStageController';
export * from './useImageCanvasStageInteractions';
export * from './useImageCanvasViewportControls';
export * from './ZoomControls';
@@ -0,0 +1,146 @@
/* @vitest-environment jsdom */
import type { CanvasLayer } from '@genarrative/image-canvas-core';
import { act, renderHook } from '@testing-library/react';
import { createRef } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useImageCanvasViewportControls } from './useImageCanvasViewportControls';
function layer(id: string, x: number): CanvasLayer {
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `media:${id}`,
x,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
};
}
function createViewport() {
const viewport = document.createElement('div');
Object.defineProperties(viewport, {
clientWidth: { configurable: true, value: 800 },
clientHeight: { configurable: true, value: 600 },
});
viewport.getBoundingClientRect = () => ({
left: 0,
top: 0,
right: 800,
bottom: 600,
width: 800,
height: 600,
x: 0,
y: 0,
toJSON: () => undefined,
});
document.body.append(viewport);
return viewport;
}
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('shared canvas React lifecycle', () => {
it('removes every native listener across repeated mounts', () => {
const viewport = createViewport();
const addListener = vi.spyOn(viewport, 'addEventListener');
const removeListener = vi.spyOn(viewport, 'removeEventListener');
const ref = createRef<HTMLDivElement>();
ref.current = viewport;
const options = {
canvasViewportRef: ref,
layers: [layer('a', 0)],
captureCanvasHistory: vi.fn(),
};
const first = renderHook(() => useImageCanvasViewportControls(options));
first.unmount();
const second = renderHook(() => useImageCanvasViewportControls(options));
second.unmount();
expect(
addListener.mock.calls.filter(([type]) => type === 'wheel'),
).toHaveLength(2);
expect(
removeListener.mock.calls.filter(([type]) => type === 'wheel'),
).toHaveLength(2);
});
it('disconnects the instance ResizeObserver on unmount', () => {
const viewport = createViewport();
const disconnect = vi.fn();
const observe = vi.fn();
vi.stubGlobal(
'ResizeObserver',
class {
observe = observe;
disconnect = disconnect;
},
);
const ref = createRef<HTMLDivElement>();
ref.current = viewport;
const hook = renderHook(() =>
useImageCanvasViewportControls({
canvasViewportRef: ref,
layers: [layer('a', 0)],
captureCanvasHistory: vi.fn(),
}),
);
expect(observe).toHaveBeenCalledWith(viewport);
hook.unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
});
it('uses the minimap inside each viewport instead of another canvas instance', () => {
const firstViewport = createViewport();
const secondViewport = createViewport();
const firstMinimap = document.createElement('button');
const secondMinimap = document.createElement('button');
firstMinimap.className = 'genarrative-image-canvas__minimap';
secondMinimap.className = 'genarrative-image-canvas__minimap';
firstMinimap.getBoundingClientRect = () =>
({ left: 10, top: 10, width: 132, height: 84 }) as DOMRect;
secondMinimap.getBoundingClientRect = () =>
({ left: 300, top: 200, width: 132, height: 84 }) as DOMRect;
firstViewport.append(firstMinimap);
secondViewport.append(secondMinimap);
const firstRef = createRef<HTMLDivElement>();
const secondRef = createRef<HTMLDivElement>();
firstRef.current = firstViewport;
secondRef.current = secondViewport;
const first = renderHook(() =>
useImageCanvasViewportControls({
canvasViewportRef: firstRef,
layers: [layer('a', 0), layer('b', 500)],
captureCanvasHistory: vi.fn(),
}),
);
const second = renderHook(() =>
useImageCanvasViewportControls({
canvasViewportRef: secondRef,
layers: [layer('c', 1000), layer('d', 1600)],
captureCanvasHistory: vi.fn(),
}),
);
const firstBefore = first.result.current.viewport;
const secondBefore = second.result.current.viewport;
act(() => first.result.current.moveViewportFromMinimapPointer(130, 80));
expect(first.result.current.viewport).not.toEqual(firstBefore);
expect(second.result.current.viewport).toEqual(secondBefore);
});
});
+200
View File
@@ -0,0 +1,200 @@
.genarrative-image-canvas {
--genarrative-image-canvas-accent: var(--image-canvas-brand-accent, #c7653d);
--genarrative-image-canvas-selection-fill: rgba(199, 101, 61, 0.12);
--genarrative-image-canvas-selection-border: rgba(199, 101, 61, 0.78);
}
.genarrative-image-canvas__viewport {
position: relative;
min-width: 0;
min-height: 0;
flex: 1;
overflow: hidden;
cursor: default;
touch-action: none;
}
.genarrative-image-canvas__viewport--panning {
cursor: grabbing;
}
.genarrative-image-canvas__viewport--tool-hand {
cursor: grab;
}
.genarrative-image-canvas__world {
position: absolute;
left: 0;
top: 0;
transform-origin: 0 0;
}
.genarrative-image-canvas__layer {
position: absolute;
display: block;
overflow: visible;
border: 2px solid transparent;
border-radius: 0.15rem;
background: transparent;
padding: 0;
cursor: move;
}
.genarrative-image-canvas__layer--locked {
cursor: not-allowed;
}
.genarrative-image-canvas__selection-overlay {
position: absolute;
z-index: 100000;
border: 1px solid var(--genarrative-image-canvas-selection-border);
background: var(--genarrative-image-canvas-selection-fill);
pointer-events: none;
}
.genarrative-image-canvas__minimap {
touch-action: none;
}
.genarrative-image-canvas__minimap-stage {
position: relative;
display: block;
width: 100%;
height: 100%;
}
.genarrative-image-canvas__minimap-layer,
.genarrative-image-canvas__minimap-viewport {
position: absolute;
}
.genarrative-image-canvas__portal {
position: relative;
z-index: 70;
}
.genarrative-image-canvas__chrome-button {
display: inline-flex;
width: 2.25rem;
height: 2.25rem;
flex: 0 0 auto;
align-items: center;
justify-content: center;
gap: 0.35rem;
box-sizing: border-box;
border: 1px solid
var(
--image-canvas-brand-border-soft,
var(--platform-icon-border, rgba(226, 203, 184, 0.82))
);
border-radius: 0.45rem;
background: var(--platform-icon-fill, #ffffff);
padding: 0;
color: var(--platform-icon-text, #334155);
font: inherit;
line-height: 1;
cursor: pointer;
transition:
transform 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.genarrative-image-canvas__chrome-button--with-label {
width: auto;
min-width: 2.25rem;
padding-inline: 0.65rem;
}
.genarrative-image-canvas__chrome-button-icon,
.genarrative-image-canvas__chrome-button-label {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
}
.genarrative-image-canvas__chrome-button-label {
font-size: 0.76rem;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.genarrative-image-canvas__chrome-button:not(:disabled):hover,
.genarrative-image-canvas__chrome-button[aria-pressed='true'] {
transform: translateY(-1px);
border-color: var(
--image-canvas-brand-border-strong,
var(--platform-nav-active-border, rgba(199, 101, 61, 0.64))
);
background: var(
--image-canvas-brand-soft,
var(--platform-nav-active-fill, rgba(234, 204, 179, 0.28))
);
color: var(
--image-canvas-brand-accent-strong,
var(--platform-text-strong, #6f2f21)
);
}
.genarrative-image-canvas__chrome-button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px
var(--image-canvas-brand-focus-ring, rgba(204, 117, 76, 0.18));
}
.genarrative-image-canvas__chrome-button:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.genarrative-image-canvas__toolbar {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 0.35rem;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: thin;
}
.genarrative-image-canvas__toolbar--floating {
border: 1px solid
var(
--image-canvas-brand-border-soft,
var(--platform-surface-border, rgba(226, 203, 184, 0.82))
);
border-radius: 0.5rem;
background: var(--platform-panel-fill, #ffffff);
padding: 0.35rem;
box-shadow: var(
--image-canvas-toolbar-shadow,
0 18px 38px rgba(15, 23, 42, 0.16)
);
}
.genarrative-image-canvas__toolbar-group {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 0.35rem;
}
.genarrative-image-canvas__toolbar-divider {
display: inline-block;
width: 1px;
height: 0.5rem;
flex: 0 0 auto;
align-self: center;
border-radius: 999px;
background: var(
--image-canvas-brand-border-soft,
var(--platform-line-soft, rgba(226, 203, 184, 0.82))
);
}
@@ -0,0 +1,322 @@
import type {
CanvasGenerationDialogState,
CanvasHistoryAction,
CanvasHistoryApplyResult,
CanvasHistoryEntry,
CanvasHistorySnapshot,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from '@genarrative/image-canvas-core';
import {
canRestoreCanvasHistorySnapshotWithoutContentLoss,
isProtectedCanvasHistoryAction,
MAX_HISTORY_STEPS,
mergeCanvasHistorySnapshotForRestore,
} from '@genarrative/image-canvas-core';
import { type RefObject, useCallback, useRef, useState } from 'react';
type CanvasHistoryRefs = {
layersRef: RefObject<CanvasLayer[]>;
viewportRef: RefObject<CanvasViewport>;
generateDialogRef?: RefObject<GenerateDialogState | null>;
inactiveGenerateDialogsRef?: RefObject<CanvasGenerationDialogState[]>;
selectedLayerIdRef?: RefObject<string | null>;
selectedLayerIdsRef: RefObject<string[]>;
};
type CanvasHistorySetters = {
setLayers: (layers: CanvasLayer[]) => void;
setViewport: (viewport: CanvasViewport) => void;
setGenerateDialog?: (dialog: GenerateDialogState | null) => void;
setInactiveGenerateDialogs?: (dialogs: CanvasGenerationDialogState[]) => void;
setSelectedLayerId?: (layerId: string | null) => void;
setSelectedLayerIds: (layerIds: string[]) => void;
};
type CanvasHistoryResetters = {
setHoveredLayerId: (layerId: string | null) => void;
setMetadataLayer: (layer: CanvasLayer | null) => void;
resetCanvasInteractionState: () => void;
};
type CanvasHistoryEffects = {
persistRestoredAssetKind?: (
layer: CanvasLayer,
historyEntryId?: number,
) => void;
};
function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
function cloneCanvasGenerationDialog(
dialog: CanvasGenerationDialogState,
): CanvasGenerationDialogState {
return {
...dialog,
placeholder: dialog.placeholder ? { ...dialog.placeholder } : undefined,
};
}
export function useCanvasHistory({
refs,
setters,
resetters,
effects,
allowContentRemovalOnRestore = false,
}: {
refs: CanvasHistoryRefs;
setters: CanvasHistorySetters;
resetters?: CanvasHistoryResetters;
effects?: CanvasHistoryEffects;
allowContentRemovalOnRestore?: boolean;
}) {
const undoStackRef = useRef<CanvasHistoryEntry[]>([]);
const redoStackRef = useRef<CanvasHistoryEntry[]>([]);
const nextHistoryEntryIdRef = useRef(0);
const [historyVersion, setHistoryVersion] = useState(0);
const getCanvasHistorySnapshot = useCallback(
(): CanvasHistorySnapshot => ({
layers: refs.layersRef.current.map((layer) => ({ ...layer })),
viewport: { ...refs.viewportRef.current },
generateDialog: refs.generateDialogRef?.current
? cloneGenerateDialog(refs.generateDialogRef.current)
: null,
inactiveGenerateDialogs: (
refs.inactiveGenerateDialogsRef?.current ?? []
).map(cloneCanvasGenerationDialog),
selectedLayerId: refs.selectedLayerIdRef?.current ?? null,
selectedLayerIds: [...refs.selectedLayerIdsRef.current],
}),
[refs],
);
const restoreCanvasHistorySnapshot = useCallback(
(
snapshot: CanvasHistorySnapshot,
action?: CanvasHistoryAction,
historyEntryId?: number,
) => {
const currentSnapshot = getCanvasHistorySnapshot();
const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({
current: currentSnapshot,
target: snapshot,
assetKindLayerIds:
action?.type === 'change-asset-kind'
? action.layerIds?.length
? new Set(action.layerIds)
: undefined
: new Set(),
layerSizeLayerIds:
action?.type === 'resize-image'
? action.layerIds?.length
? new Set(action.layerIds)
: new Set(snapshot.layers.map((layer) => layer.id))
: new Set(),
});
setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer })));
setters.setViewport({ ...mergedSnapshot.viewport });
setters.setGenerateDialog?.(
mergedSnapshot.generateDialog
? cloneGenerateDialog(mergedSnapshot.generateDialog)
: null,
);
setters.setInactiveGenerateDialogs?.(
mergedSnapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog),
);
setters.setSelectedLayerId?.(mergedSnapshot.selectedLayerId);
setters.setSelectedLayerIds([...mergedSnapshot.selectedLayerIds]);
resetters?.setHoveredLayerId(null);
resetters?.setMetadataLayer(null);
resetters?.resetCanvasInteractionState();
if (action?.type === 'change-asset-kind') {
const currentLayerById = new Map(
currentSnapshot.layers.map((layer) => [layer.id, layer] as const),
);
for (const layer of mergedSnapshot.layers) {
const currentLayer = currentLayerById.get(layer.id);
if (currentLayer && currentLayer.assetKind !== layer.assetKind) {
effects?.persistRestoredAssetKind?.({ ...layer }, historyEntryId);
}
}
}
},
[effects, getCanvasHistorySnapshot, resetters, setters],
);
const captureCanvasHistory = useCallback(
(
action: CanvasHistoryAction,
options: { snapshot?: CanvasHistorySnapshot } = {},
) => {
nextHistoryEntryIdRef.current += 1;
const entryId = nextHistoryEntryIdRef.current;
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
{
entryId,
snapshot: options.snapshot ?? getCanvasHistorySnapshot(),
action,
createdAt: Date.now(),
},
];
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
return entryId;
},
[getCanvasHistorySnapshot],
);
const discardCanvasHistoryEntriesContainingLayer = useCallback(
(matchesLayer: (layer: CanvasLayer) => boolean) => {
const keepEntry = (entry: CanvasHistoryEntry) =>
!entry.snapshot.layers.some(matchesLayer);
const nextUndoStack = undoStackRef.current.filter(keepEntry);
const nextRedoStack = redoStackRef.current.filter(keepEntry);
if (
nextUndoStack.length === undoStackRef.current.length &&
nextRedoStack.length === redoStackRef.current.length
) {
return;
}
undoStackRef.current = nextUndoStack;
redoStackRef.current = nextRedoStack;
setHistoryVersion((version) => version + 1);
},
[],
);
const discardCanvasHistoryEntriesById = useCallback(
(entryIds: ReadonlySet<number>) => {
if (entryIds.size === 0) {
return;
}
const keepEntry = (entry: CanvasHistoryEntry) =>
!entryIds.has(entry.entryId);
const nextUndoStack = undoStackRef.current.filter(keepEntry);
const nextRedoStack = redoStackRef.current.filter(keepEntry);
if (
nextUndoStack.length === undoStackRef.current.length &&
nextRedoStack.length === redoStackRef.current.length
) {
return;
}
undoStackRef.current = nextUndoStack;
redoStackRef.current = nextRedoStack;
setHistoryVersion((version) => version + 1);
},
[],
);
const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const previousEntry = undoStackRef.current.at(-1);
if (!previousEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
!allowContentRemovalOnRestore &&
(isProtectedCanvasHistoryAction(previousEntry.action) ||
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: previousEntry.snapshot,
}))
) {
return {
status: 'blocked',
action: previousEntry.action,
reason: 'content-may-disappear',
};
}
undoStackRef.current = undoStackRef.current.slice(0, -1);
redoStackRef.current = [
...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
{
entryId: previousEntry.entryId,
snapshot: currentSnapshot,
action: previousEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(
previousEntry.snapshot,
previousEntry.action,
previousEntry.entryId,
);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: previousEntry.action };
}, [
allowContentRemovalOnRestore,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
]);
const redoCanvasChange = useCallback((): CanvasHistoryApplyResult => {
const nextEntry = redoStackRef.current.at(-1);
if (!nextEntry) {
return { status: 'empty' };
}
const currentSnapshot = getCanvasHistorySnapshot();
if (
!allowContentRemovalOnRestore &&
!canRestoreCanvasHistorySnapshotWithoutContentLoss({
current: currentSnapshot,
target: nextEntry.snapshot,
})
) {
return {
status: 'blocked',
action: nextEntry.action,
reason: 'content-may-disappear',
};
}
redoStackRef.current = redoStackRef.current.slice(0, -1);
undoStackRef.current = [
...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)),
{
entryId: nextEntry.entryId,
snapshot: currentSnapshot,
action: nextEntry.action,
createdAt: Date.now(),
},
];
restoreCanvasHistorySnapshot(
nextEntry.snapshot,
nextEntry.action,
nextEntry.entryId,
);
setHistoryVersion((version) => version + 1);
return { status: 'success', action: nextEntry.action };
}, [
allowContentRemovalOnRestore,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
]);
const resetCanvasHistory = useCallback(() => {
undoStackRef.current = [];
redoStackRef.current = [];
setHistoryVersion((version) => version + 1);
}, []);
return {
canUndo: undoStackRef.current.length > 0,
canRedo: redoStackRef.current.length > 0,
historyVersion,
getCanvasHistorySnapshot,
restoreCanvasHistorySnapshot,
captureCanvasHistory,
discardCanvasHistoryEntriesContainingLayer,
discardCanvasHistoryEntriesById,
undoCanvasChange,
redoCanvasChange,
resetCanvasHistory,
};
}
@@ -0,0 +1,189 @@
import type {
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
ImageContextMenuState,
} from '@genarrative/image-canvas-core';
import {
type Dispatch,
type MouseEvent as ReactMouseEvent,
type SetStateAction,
useCallback,
useMemo,
} from 'react';
function getBrowserViewportBounds() {
return typeof window === 'undefined'
? undefined
: { width: window.innerWidth, height: window.innerHeight };
}
import { getCanvasGenerationSelectionId } from '@genarrative/image-canvas-core';
import {
createBlankCanvasContextMenu,
createGenerationDialogCanvasContextMenu,
createLayerCanvasContextMenus,
resolveImageCanvasStageControllerModel,
} from '@genarrative/image-canvas-core';
type UseImageCanvasStageControllerOptions = {
layers: CanvasLayer[];
canvasGenerationDialogs?: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
setSelectedLayerId: Dispatch<SetStateAction<string | null>>;
setSelectedLayerIds: Dispatch<SetStateAction<string[]>>;
imageContextMenu: ImageContextMenuState | null;
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
contextMenu: CanvasContextMenuState | null;
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
viewport: CanvasViewport;
canvasSize: { width: number; height: number };
selectSingleLayer: (layerId: string | null) => void;
hideGeneratedLayerPanelAfterBlur: () => void;
clearGenerationPanelsAfterBlur?: () => void;
getCanvasPointFromClient: (
clientX: number,
clientY: number,
) => { x: number; y: number };
};
export function useImageCanvasStageController({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
imageContextMenu,
setImageContextMenu,
contextMenu,
setContextMenu,
viewport,
canvasSize,
selectSingleLayer,
hideGeneratedLayerPanelAfterBlur,
clearGenerationPanelsAfterBlur,
getCanvasPointFromClient,
}: UseImageCanvasStageControllerOptions) {
const model = useMemo(
() =>
resolveImageCanvasStageControllerModel({
layers,
canvasGenerationDialogs,
selectedLayerId,
selectedLayerIds,
imageContextMenu,
contextMenu,
viewport,
canvasSize,
}),
[
canvasSize,
canvasGenerationDialogs,
contextMenu,
imageContextMenu,
layers,
selectedLayerId,
selectedLayerIds,
viewport,
],
);
const clearCanvasFocus = useCallback(() => {
selectSingleLayer(null);
hideGeneratedLayerPanelAfterBlur();
clearGenerationPanelsAfterBlur?.();
setImageContextMenu(null);
setContextMenu(null);
}, [
clearGenerationPanelsAfterBlur,
hideGeneratedLayerPanelAfterBlur,
selectSingleLayer,
setContextMenu,
setImageContextMenu,
]);
const handleCanvasContextMenu = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setImageContextMenu(null);
setContextMenu(
createBlankCanvasContextMenu({
clientX: event.clientX,
clientY: event.clientY,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
viewportBounds: getBrowserViewportBounds(),
}),
);
},
[getCanvasPointFromClient, setContextMenu, setImageContextMenu],
);
const handleLayerContextMenu = useCallback(
(event: ReactMouseEvent<HTMLElement>, layer: CanvasLayer) => {
event.preventDefault();
event.stopPropagation();
if (!selectedLayerIds.includes(layer.id)) {
selectSingleLayer(layer.id);
}
const nextMenus = createLayerCanvasContextMenus({
clientX: event.clientX,
clientY: event.clientY,
layerId: layer.id,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
viewportBounds: getBrowserViewportBounds(),
});
setContextMenu(nextMenus.contextMenu);
setImageContextMenu(nextMenus.imageContextMenu);
},
[
getCanvasPointFromClient,
selectSingleLayer,
selectedLayerIds,
setContextMenu,
setImageContextMenu,
],
);
const handleGenerationFrameContextMenu = useCallback(
(
event: ReactMouseEvent<HTMLElement>,
dialog: CanvasGenerationDialogState,
) => {
event.preventDefault();
event.stopPropagation();
const selectionId = getCanvasGenerationSelectionId(dialog.id);
if (!selectedLayerIds.includes(selectionId)) {
setSelectedLayerId(null);
setSelectedLayerIds([selectionId]);
}
const nextMenu = createGenerationDialogCanvasContextMenu({
clientX: event.clientX,
clientY: event.clientY,
dialogId: dialog.id,
canvasPoint: getCanvasPointFromClient(event.clientX, event.clientY),
viewportBounds: getBrowserViewportBounds(),
});
setContextMenu(nextMenu);
setImageContextMenu(null);
},
[
getCanvasPointFromClient,
selectedLayerIds,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
return {
...model,
clearCanvasFocus,
handleCanvasContextMenu,
handleLayerContextMenu,
handleGenerationFrameContextMenu,
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,420 @@
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,
};
}