import { CanvasChromeButton, Minimap, ZoomControls, } from '@genarrative/image-canvas-react'; 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 { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { CANVAS_BACKGROUND_OPTIONS, clamp, DEFAULT_CANVAS_BACKGROUND_COLOR, 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) => 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, 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, 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, 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) { 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) => { event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); onApplyCanvasBackgroundColor( resolveSpectrumPointerColor( event, canvasBackgroundColor, backgroundHsv.hue, ), ); }; const applyHuePointerColor = (event: ReactPointerEvent) => { event.preventDefault(); event.currentTarget.setPointerCapture?.(event.pointerId); onApplyCanvasBackgroundColor( resolveHuePointerColor(event, canvasBackgroundColor, backgroundHsv.hue), ); }; const handleSpectrumKeyDown = ( event: ReactKeyboardEvent, ) => { 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) => { 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 ( <> onFitLayers()} />
event.stopPropagation()} > {(zoomActions) => (
{zoomActions.displayPercent} {isZoomMenuOpen ? ( {[ { label: '放大', action: zoomActions.zoomIn }, { label: '缩小', action: zoomActions.zoomOut }, { label: '显示画布所有元素', action: zoomActions.fit, }, ].map(({ label, action }) => ( { action(); onCloseZoomMenu(); }} > {label} ))} {[0.5, 1, 2].map((displayScale) => ( { zoomActions.zoomToDisplayScale(displayScale); onCloseZoomMenu(); }} > 缩放至{Math.round(displayScale * 100)}% ))} ) : null}
)}
} /> {isBackgroundSettingsOpen ? (
画布背景
{CANVAS_BACKGROUND_OPTIONS.map((option) => ( ))}
) : null}
onToggleSidebarPanel('assets')} /> onToggleSidebarPanel('layers')} /> {isAgentConversationEnabled ? ( ) : null}
{isMinimapOpen && minimapModel ? ( ) : null} ); }