Files
Genarrative/src/components/image-editor/ImageCanvasPanelDockView.tsx
zhaojingyan eb608ac6f2 完善图片画布撤销保护与操作提示 (#89)
为画布历史记录补充操作类型和内容消失保护

增加撤销成功、恢复成功及阻止提示并在三秒后隐藏

补充拖动、上传、生成和替换等操作的历史语义

保留恢复按钮、Ctrl+Shift+Z 快捷键和双向历史栈

补充撤销与恢复规则的定向测试和说明文档

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/89
Co-authored-by: oj-afraid-student <1373241747@qq.com>
Co-committed-by: oj-afraid-student <1373241747@qq.com>
2026-07-20 15:19:48 +08:00

591 lines
20 KiB
TypeScript

import {
ImagePlus,
Layers,
Map as MapIcon,
MessageCircle,
Redo2,
RotateCcw,
Undo2,
X,
} from 'lucide-react';
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from 'react';
import { useEffect, useRef } from 'react';
import {
PlatformFloatingMenu,
PlatformFloatingMenuItem,
} from '../common/PlatformFloatingMenu';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
import {
CANVAS_BACKGROUND_OPTIONS,
canvasDisplayScaleToViewportScale,
clamp,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatCanvasDisplayScalePercent,
normalizeCanvasBackgroundHex,
} from './ImageCanvasEditorModel';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
import type { StageMinimapModel } from './ImageCanvasInteractionModel';
type ImageCanvasPanelDockViewProps = {
viewport: CanvasViewport;
canvasBackgroundColor: string;
canvasBackgroundHexValue: string;
canUndo: boolean;
canRedo: boolean;
isZoomMenuOpen: boolean;
isBackgroundSettingsOpen: boolean;
activeSidebarPanel: SidebarPanel | null;
isAgentConversationEnabled: boolean;
isAgentConversationOpen: boolean;
isMinimapOpen: boolean;
minimapModel: StageMinimapModel | null;
onFitLayers: () => void;
onUndoCanvasChange: () => void;
onRedoCanvasChange: () => void;
onUpdateScaleFromCenter: (nextScale: number) => void;
onToggleZoomMenu: () => void;
onCloseZoomMenu: () => void;
onToggleBackgroundSettings: () => void;
onApplyCanvasBackgroundColor: (color: string) => void;
onCanvasBackgroundHexChange: (value: string) => void;
onToggleSidebarPanel: (panel: SidebarPanel) => void;
onToggleAgentConversation: () => void;
onToggleMinimap: () => void;
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
};
type CanvasBackgroundHsv = {
hue: number;
saturation: number;
value: number;
};
const DEFAULT_CANVAS_BACKGROUND_PICKER_HUE = 210;
const ACHROMATIC_BACKGROUND_SATURATION_EPSILON = 0.001;
function hexToRgb(value: string) {
const normalizedValue = normalizeCanvasBackgroundHex(value);
if (!normalizedValue) {
return null;
}
return {
red: Number.parseInt(normalizedValue.slice(1, 3), 16),
green: Number.parseInt(normalizedValue.slice(3, 5), 16),
blue: Number.parseInt(normalizedValue.slice(5, 7), 16),
};
}
function rgbToHsv(red: number, green: number, blue: number) {
const normalizedRed = red / 255;
const normalizedGreen = green / 255;
const normalizedBlue = blue / 255;
const maxValue = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
const minValue = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
const delta = maxValue - minValue;
const saturation = maxValue === 0 ? 0 : delta / maxValue;
let hue = 0;
if (delta > 0) {
if (maxValue === normalizedRed) {
hue = 60 * (((normalizedGreen - normalizedBlue) / delta) % 6);
} else if (maxValue === normalizedGreen) {
hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2);
} else {
hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4);
}
}
return {
hue: (hue + 360) % 360,
saturation,
value: maxValue,
};
}
function hsvToHex({ hue, saturation, value }: CanvasBackgroundHsv) {
const chroma = value * saturation;
const normalizedHue = ((hue % 360) + 360) % 360;
const secondComponent =
chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
const matchValue = value - chroma;
const [redPrime, greenPrime, bluePrime] =
normalizedHue < 60
? [chroma, secondComponent, 0]
: normalizedHue < 120
? [secondComponent, chroma, 0]
: normalizedHue < 180
? [0, chroma, secondComponent]
: normalizedHue < 240
? [0, secondComponent, chroma]
: normalizedHue < 300
? [secondComponent, 0, chroma]
: [chroma, 0, secondComponent];
return `#${[redPrime, greenPrime, bluePrime]
.map((channel) =>
Math.round((channel + matchValue) * 255)
.toString(16)
.padStart(2, '0'),
)
.join('')}`;
}
function resolveCanvasBackgroundHsv(
color: string,
fallbackHue = DEFAULT_CANVAS_BACKGROUND_PICKER_HUE,
): CanvasBackgroundHsv {
const rgb = hexToRgb(color) ?? hexToRgb(DEFAULT_CANVAS_BACKGROUND_COLOR);
if (!rgb) {
return { hue: fallbackHue, saturation: 0.02, value: 0.99 };
}
const hsv = rgbToHsv(rgb.red, rgb.green, rgb.blue);
return {
...hsv,
hue:
hsv.saturation <= ACHROMATIC_BACKGROUND_SATURATION_EPSILON
? fallbackHue
: hsv.hue,
};
}
function getPointerRatio(
event: ReactPointerEvent<HTMLElement>,
axis: 'x' | 'y',
) {
const rect = event.currentTarget.getBoundingClientRect();
const size = axis === 'x' ? rect.width : rect.height;
if (!Number.isFinite(size) || size <= 0) {
return 0.5;
}
const clientPosition = axis === 'x' ? event.clientX : event.clientY;
if (!Number.isFinite(clientPosition)) {
return 0.5;
}
const offset =
axis === 'x' ? clientPosition - rect.left : clientPosition - rect.top;
return clamp(offset / size, 0, 1);
}
function resolveSpectrumPointerColor(
event: ReactPointerEvent<HTMLElement>,
currentColor: string,
fallbackHue: number,
) {
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
return hsvToHex({
hue: currentHsv.hue,
saturation: getPointerRatio(event, 'x'),
value: 1 - getPointerRatio(event, 'y'),
});
}
function resolveHuePointerColor(
event: ReactPointerEvent<HTMLElement>,
currentColor: string,
fallbackHue: number,
) {
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
return hsvToHex({
hue: getPointerRatio(event, 'x') * 360,
saturation: currentHsv.saturation,
value: currentHsv.value,
});
}
function isPrimaryPointerDrag(event: ReactPointerEvent<HTMLElement>) {
return event.buttons === 1 || event.pointerType === 'touch';
}
export function ImageCanvasPanelDockView({
viewport,
canvasBackgroundColor,
canvasBackgroundHexValue,
canUndo,
canRedo,
isZoomMenuOpen,
isBackgroundSettingsOpen,
activeSidebarPanel,
isAgentConversationEnabled,
isAgentConversationOpen,
isMinimapOpen,
minimapModel,
onFitLayers,
onUndoCanvasChange,
onRedoCanvasChange,
onUpdateScaleFromCenter,
onToggleZoomMenu,
onCloseZoomMenu,
onToggleBackgroundSettings,
onApplyCanvasBackgroundColor,
onCanvasBackgroundHexChange,
onToggleSidebarPanel,
onToggleAgentConversation,
onToggleMinimap,
onMinimapPointerDown,
}: ImageCanvasPanelDockViewProps) {
const lastBackgroundHueRef = useRef(DEFAULT_CANVAS_BACKGROUND_PICKER_HUE);
const backgroundHsv = resolveCanvasBackgroundHsv(
canvasBackgroundColor,
lastBackgroundHueRef.current,
);
useEffect(() => {
if (
backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON
) {
lastBackgroundHueRef.current = backgroundHsv.hue;
}
}, [backgroundHsv.hue, backgroundHsv.saturation]);
const backgroundHueColor = hsvToHex({
hue: backgroundHsv.hue,
saturation: 1,
value: 1,
});
const backgroundSpectrumStyle = {
'--image-canvas-background-hue-color': backgroundHueColor,
} as CSSProperties;
const backgroundSpectrumHandleStyle = {
'--image-canvas-background-spectrum-handle-left': `clamp(0.42rem, ${backgroundHsv.saturation * 100}%, calc(100% - 0.42rem))`,
'--image-canvas-background-spectrum-handle-top': `clamp(0.42rem, ${(1 - backgroundHsv.value) * 100}%, calc(100% - 0.42rem))`,
} as CSSProperties;
const backgroundHueHandleStyle = {
'--image-canvas-background-hue-handle-left': `clamp(0.39rem, ${(backgroundHsv.hue / 360) * 100}%, calc(100% - 0.39rem))`,
} as CSSProperties;
const applySpectrumPointerColor = (
event: ReactPointerEvent<HTMLElement>,
) => {
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
onApplyCanvasBackgroundColor(
resolveSpectrumPointerColor(
event,
canvasBackgroundColor,
backgroundHsv.hue,
),
);
};
const applyHuePointerColor = (event: ReactPointerEvent<HTMLElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
onApplyCanvasBackgroundColor(
resolveHuePointerColor(event, canvasBackgroundColor, backgroundHsv.hue),
);
};
const handleSpectrumKeyDown = (
event: ReactKeyboardEvent<HTMLButtonElement>,
) => {
const step = event.shiftKey ? 0.1 : 0.03;
const nextHsv = { ...backgroundHsv };
if (event.key === 'ArrowLeft') {
nextHsv.saturation = clamp(nextHsv.saturation - step, 0, 1);
} else if (event.key === 'ArrowRight') {
nextHsv.saturation = clamp(nextHsv.saturation + step, 0, 1);
} else if (event.key === 'ArrowDown') {
nextHsv.value = clamp(nextHsv.value - step, 0, 1);
} else if (event.key === 'ArrowUp') {
nextHsv.value = clamp(nextHsv.value + step, 0, 1);
} else {
return;
}
event.preventDefault();
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
};
const handleHueKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
const step = event.shiftKey ? 30 : 6;
const nextHsv = { ...backgroundHsv };
if (event.key === 'ArrowLeft') {
nextHsv.hue = (nextHsv.hue - step + 360) % 360;
} else if (event.key === 'ArrowRight') {
nextHsv.hue = (nextHsv.hue + step) % 360;
} else {
return;
}
event.preventDefault();
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
};
return (
<>
<EditorIconButton
className="image-canvas-editor__reset-button"
label="重置画布视图"
title="重置画布视图"
icon={RotateCcw}
onClick={() => onFitLayers()}
/>
<div
className={[
'image-canvas-editor__panel-dock',
activeSidebarPanel
? 'image-canvas-editor__panel-dock--sidebar-open'
: '',
]
.filter(Boolean)
.join(' ')}
role="toolbar"
aria-label="画布面板入口"
onPointerDown={(event) => event.stopPropagation()}
>
<EditorIconButton
label="撤销"
title="撤销"
icon={Undo2}
disabled={!canUndo}
onClick={onUndoCanvasChange}
/>
<EditorIconButton
label="恢复"
title="恢复上一次撤销"
icon={Redo2}
disabled={!canRedo}
onClick={onRedoCanvasChange}
/>
<div className="image-canvas-editor__zoom-menu-wrap">
<PlatformInlineOptionButton
className="image-canvas-editor__zoom-trigger"
aria-label={`当前缩放比例 ${formatCanvasDisplayScalePercent(viewport.scale)}`}
aria-haspopup="menu"
aria-expanded={isZoomMenuOpen}
onClick={onToggleZoomMenu}
>
{formatCanvasDisplayScalePercent(viewport.scale)}
</PlatformInlineOptionButton>
{isZoomMenuOpen ? (
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
onUpdateScaleFromCenter(viewport.scale * 1.16);
onCloseZoomMenu();
}}
>
放大
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
onUpdateScaleFromCenter(viewport.scale * 0.86);
onCloseZoomMenu();
}}
>
缩小
</PlatformFloatingMenuItem>
<PlatformFloatingMenuItem
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
onFitLayers();
onCloseZoomMenu();
}}
>
显示画布所有元素
</PlatformFloatingMenuItem>
{[0.5, 1, 2].map((displayScale) => (
<PlatformFloatingMenuItem
key={displayScale}
className="image-canvas-editor__zoom-menu-item"
onClick={() => {
onUpdateScaleFromCenter(
canvasDisplayScaleToViewportScale(displayScale),
);
onCloseZoomMenu();
}}
>
缩放至{Math.round(displayScale * 100)}%
</PlatformFloatingMenuItem>
))}
</PlatformFloatingMenu>
) : null}
</div>
<div className="image-canvas-editor__background-control">
<PlatformIconButton
label="画布背景色"
title="画布背景色"
aria-expanded={isBackgroundSettingsOpen}
onClick={onToggleBackgroundSettings}
icon={
<span
className="image-canvas-editor__background-swatch-current"
style={{ backgroundColor: canvasBackgroundColor }}
/>
}
/>
{isBackgroundSettingsOpen ? (
<div
className="image-canvas-editor__background-panel"
role="dialog"
aria-label="画布背景设置"
>
<div className="image-canvas-editor__background-panel-head">
<span>画布背景</span>
<button
type="button"
className="image-canvas-editor__background-close"
aria-label="关闭画布背景设置"
onClick={onToggleBackgroundSettings}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="image-canvas-editor__background-current-row">
<span
className="image-canvas-editor__background-current-preview"
style={{ backgroundColor: canvasBackgroundColor }}
aria-hidden="true"
/>
<span>{canvasBackgroundColor}</span>
</div>
<button
type="button"
className="image-canvas-editor__background-spectrum"
aria-label="画布背景色盘"
aria-valuetext={canvasBackgroundColor}
style={backgroundSpectrumStyle}
onPointerDown={applySpectrumPointerColor}
onPointerMove={(event) => {
if (isPrimaryPointerDrag(event)) {
applySpectrumPointerColor(event);
}
}}
onKeyDown={handleSpectrumKeyDown}
>
<span
className="image-canvas-editor__background-spectrum-surface"
aria-hidden="true"
/>
<span
className="image-canvas-editor__background-spectrum-handle"
aria-hidden="true"
style={backgroundSpectrumHandleStyle}
/>
</button>
<button
type="button"
className="image-canvas-editor__background-hue"
aria-label="画布背景色相"
aria-valuetext={canvasBackgroundColor}
onPointerDown={applyHuePointerColor}
onPointerMove={(event) => {
if (isPrimaryPointerDrag(event)) {
applyHuePointerColor(event);
}
}}
onKeyDown={handleHueKeyDown}
>
<span
className="image-canvas-editor__background-hue-handle"
aria-hidden="true"
style={backgroundHueHandleStyle}
/>
</button>
<div
className="image-canvas-editor__background-presets"
aria-label="画布背景预设色"
>
{CANVAS_BACKGROUND_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className="image-canvas-editor__background-preset"
aria-label={option.label}
aria-pressed={canvasBackgroundColor === option.value}
onClick={() => onApplyCanvasBackgroundColor(option.value)}
>
<span
className="image-canvas-editor__background-swatch"
style={{ backgroundColor: option.value }}
/>
</button>
))}
</div>
<div className="image-canvas-editor__background-footer">
<label className="image-canvas-editor__background-hex-field">
<span>HEX</span>
<input
aria-label="画布背景十六进制颜色"
value={canvasBackgroundHexValue}
spellCheck={false}
onChange={(event) =>
onCanvasBackgroundHexChange(event.currentTarget.value)
}
/>
</label>
<button
type="button"
className="image-canvas-editor__background-reset"
onClick={() =>
onApplyCanvasBackgroundColor(
DEFAULT_CANVAS_BACKGROUND_COLOR,
)
}
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
恢复默认
</button>
</div>
</div>
) : null}
</div>
<EditorIconButton
label="打开素材"
title="素材"
icon={ImagePlus}
pressed={activeSidebarPanel === 'assets'}
onClick={() => onToggleSidebarPanel('assets')}
/>
<EditorIconButton
label="打开图层"
title="图层"
icon={Layers}
pressed={activeSidebarPanel === 'layers'}
onClick={() => onToggleSidebarPanel('layers')}
/>
{isAgentConversationEnabled ? (
<EditorIconButton
label="画布 Agent"
title="画布 Agent"
icon={MessageCircle}
pressed={isAgentConversationOpen}
onClick={onToggleAgentConversation}
/>
) : null}
<EditorIconButton
label="切换小地图"
title="小地图"
icon={MapIcon}
pressed={isMinimapOpen}
onClick={onToggleMinimap}
/>
</div>
{isMinimapOpen && minimapModel ? (
<button
type="button"
className="image-canvas-editor__minimap"
aria-label="画布小地图"
title="拖拽移动视图"
onPointerDown={onMinimapPointerDown}
>
<span className="image-canvas-editor__minimap-stage">
{minimapModel.layers.map((layer) => (
<span
key={layer.id}
className="image-canvas-editor__minimap-layer"
title={layer.title}
style={layer.rect}
/>
))}
<span
className="image-canvas-editor__minimap-viewport"
style={minimapModel.viewport}
/>
</span>
</button>
) : null}
</>
);
}