合并master并保留Spine序列帧修复
合并master当前工程、后端、前端和文档更新 按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突 保留当前分支底部工具栏宽度与隐藏滚动条样式
This commit is contained in:
@@ -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)}</>;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user