diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index d57049cbe..e5af5a158 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -5,6 +5,7 @@ import { canvasDisplayViewportToViewport, CANVAS_WORLD_ORIGIN, createLayerFromAsset, + DEFAULT_CANVAS_BACKGROUND_COLOR, formatCanvasDisplayScalePercent, hydrateLayer, normalizeAssetLibrary, @@ -49,6 +50,42 @@ describe('ImageCanvasEditorModel', () => { expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull(); }); + it('serializes canvas background settings without treating them as layers', () => { + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [], + canvasBackgroundColor: ' #ABC ', + }); + + expect(layout).toEqual([ + expect.objectContaining({ + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#aabbcc', + }), + ]); + + const { layerItems, generationDialogs, canvasBackgroundColor } = + splitCanvasLayoutItems(layout); + + expect(layerItems).toEqual([]); + expect(generationDialogs).toEqual([]); + expect(canvasBackgroundColor).toBe('#aabbcc'); + }); + + it('drops invalid canvas background settings from serialized layouts', () => { + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [], + canvasBackgroundColor: '#not-a-color', + }); + + expect(layout).toEqual([]); + expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined(); + expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc'); + }); + it('keeps only one default asset folder when normalizing the persisted library', () => { const library = normalizeAssetLibrary({ folders: [ diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 830c27ec8..eb756a7b6 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -300,8 +300,15 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & { dialog: CanvasGenerationDialogState; }; +type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & { + itemType: 'canvas-settings'; + canvasBackgroundColor?: string; +}; + export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; +const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; + function isPersistedReferenceResourceId(resourceId: string | null | undefined) { const normalizedResourceId = resourceId?.trim(); return Boolean( @@ -419,14 +426,37 @@ export function serializeCanvasGenerationDialog( }; } +function serializeCanvasSettings({ + canvasBackgroundColor, +}: { + canvasBackgroundColor?: string | null; +}): CanvasSettingsLayoutSnapshot | null { + const normalizedBackgroundColor = canvasBackgroundColor + ? normalizeCanvasBackgroundHex(canvasBackgroundColor) + : null; + if (!normalizedBackgroundColor) { + return null; + } + return { + itemType: 'canvas-settings', + layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID, + resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID, + canvasBackgroundColor: normalizedBackgroundColor, + }; +} + export function serializeCanvasLayout({ layers, canvasGenerationDialogs, + canvasBackgroundColor, }: { layers: CanvasLayer[]; canvasGenerationDialogs: CanvasGenerationDialogState[]; + canvasBackgroundColor?: string | null; }): CanvasLayoutItems { + const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor }); return [ + ...(canvasSettings ? [canvasSettings] : []), ...layers.map(serializeLayer), ...canvasGenerationDialogs.map(serializeCanvasGenerationDialog), ]; @@ -445,6 +475,12 @@ export function isCanvasGenerationDialogLayoutItem( ); } +function isCanvasSettingsLayoutItem( + item: EditorProjectLayerSnapshot, +): item is CanvasSettingsLayoutSnapshot { + return item.itemType === 'canvas-settings'; +} + export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), @@ -452,11 +488,23 @@ export function splitCanvasLayoutItems( ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; + canvasBackgroundColor?: string; } { const layerItems: EditorProjectLayerSnapshot[] = []; const generationDialogs: CanvasGenerationDialogState[] = []; + let canvasBackgroundColor: string | undefined; items.forEach((item) => { + if (isCanvasSettingsLayoutItem(item)) { + const normalizedBackgroundColor = + typeof item.canvasBackgroundColor === 'string' + ? normalizeCanvasBackgroundHex(item.canvasBackgroundColor) + : null; + if (normalizedBackgroundColor) { + canvasBackgroundColor = normalizedBackgroundColor; + } + return; + } if (isCanvasGenerationDialogLayoutItem(item)) { const dialog = hydrateCanvasGenerationDialog( item.dialog, @@ -471,7 +519,7 @@ export function splitCanvasLayoutItems( layerItems.push(item); }); - return { layerItems, generationDialogs }; + return { layerItems, generationDialogs, canvasBackgroundColor }; } export function hydrateCanvasGenerationDialog( diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 7387b70c8..300cdae39 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -314,6 +314,46 @@ describe('ImageCanvasEditorView', () => { expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled(); }); + it('restores the canvas background color from the persisted project layout', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-background', + title: '背景色项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + itemType: 'canvas-settings', + layerId: 'canvas-settings:default', + resourceId: 'canvas-settings:default', + canvasBackgroundColor: '#112233', + }, + ], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }); + + render(); + + const viewport = screen.getByLabelText('画布工作区'); + await waitFor(() => { + expect((viewport as HTMLElement).style.backgroundColor).toBe( + 'rgb(17, 34, 51)', + ); + }); + + const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' }); + fireEvent.click( + within(panelToolbar).getByRole('button', { name: '画布背景色' }), + ); + + expect( + ( + within( + screen.getByRole('dialog', { name: '画布背景设置' }), + ).getByLabelText('画布背景十六进制颜色') as HTMLInputElement + ).value, + ).toBe('#112233'); + }); + it('shows the toolbar guide for a newly created blank project until a generator opens', async () => { loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-guide', @@ -500,7 +540,7 @@ describe('ImageCanvasEditorView', () => { expect(openAccountModal).toHaveBeenCalledTimes(1); }); - it('opens the same account wallet entry from the canvas topbar mud point button', async () => { + it('opens the account recharge entry from the canvas topbar mud point button', async () => { render( { fireEvent.click(walletButton); - expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy(); - expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy(); + expect( + await screen.findByRole('dialog', { name: '账户充值' }), + ).toBeTruthy(); + expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull(); }); it('opens the login modal immediately when entering the editor while logged out', async () => { @@ -1724,9 +1766,11 @@ describe('ImageCanvasEditorView', () => { }); expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy(); + expect(within(settingsPanel).getByLabelText('画布背景色盘')).toBeTruthy(); expect( within(settingsPanel).getByLabelText('画布背景十六进制颜色'), ).toBeTruthy(); + expect(settingsPanel.querySelector('input[type="color"]')).toBeNull(); fireEvent.click( within(settingsPanel).getByRole('button', { name: '暖灰' }), @@ -1736,13 +1780,6 @@ describe('ImageCanvasEditorView', () => { 'rgb(243, 240, 234)', ); - fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), { - target: { value: '#ffffff' }, - }); - expect((viewport as HTMLElement).style.backgroundColor).toBe( - 'rgb(255, 255, 255)', - ); - const hexInput = within(settingsPanel).getByLabelText('画布背景十六进制颜色'); fireEvent.change(hexInput, { target: { value: '#abc' } }); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index d7296bfd9..52e1ed731 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -34,6 +34,7 @@ import { usePlatformProfileCenterController } from '../platform-entry/usePlatfor import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation'; import { canvasAssetKindOrNull, + DEFAULT_CANVAS_BACKGROUND_COLOR, generationInputsOrNull, isInlineEditorMediaSource, resolveContextMenuPosition, @@ -308,6 +309,7 @@ export function ImageCanvasEditorView({ const layersRef = useRef([]); const canvasGenerationDialogsRef = useRef([]); const viewportRef = useRef(DEFAULT_IMAGE_CANVAS_VIEWPORT); + const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR); const captureCanvasHistoryRef = useRef<() => void>(() => {}); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const closeGenerationTransientStateRef = useRef<() => void>(() => {}); @@ -623,6 +625,7 @@ export function ImageCanvasEditorView({ toggleBackgroundSettings, toggleMinimap, } = useImageCanvasEditorChrome({ openEditorLoginModal }); + canvasBackgroundColorRef.current = canvasBackgroundColor; const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ layers, setLayers, @@ -1031,6 +1034,7 @@ export function ImageCanvasEditorView({ layersRef, viewportRef, canvasGenerationDialogsRef, + canvasBackgroundColorRef, }), [], ); @@ -1045,8 +1049,10 @@ export function ImageCanvasEditorView({ layerCounterRef.current = value; }, restoreCanvasGenerationDialogs, + applyCanvasBackgroundColor, }), [ + applyCanvasBackgroundColor, restoreCanvasGenerationDialogs, selectSingleLayer, setLayers, @@ -1066,6 +1072,7 @@ export function ImageCanvasEditorView({ layers, canvasGenerationDialogs, viewport, + canvasBackgroundColor, isViewportInteracting, canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true, currentUserId: currentEditorUserId, diff --git a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx index d033bf634..a594faf01 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.test.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.test.tsx @@ -43,6 +43,23 @@ function renderPanelDock( return props; } +function mockElementRect( + element: Element, + rect: Pick, +) { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + ...rect, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + x: rect.left, + y: rect.top, + toJSON: () => ({}), + }), + }); +} + describe('ImageCanvasPanelDockView', () => { it('renders panel dock actions and forwards common controls', () => { const props = renderPanelDock({ @@ -98,9 +115,71 @@ describe('ImageCanvasPanelDockView', () => { expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull(); }); + it('keeps the background picker visible when the selected color is white', () => { + const props = renderPanelDock({ + canvasBackgroundColor: '#ffffff', + canvasBackgroundHexValue: '#ffffff', + isBackgroundSettingsOpen: true, + }); + + const panel = screen.getByRole('dialog', { name: '画布背景设置' }); + const spectrumButton = within(panel).getByRole('button', { + name: '画布背景色盘', + }); + const hueButton = within(panel).getByRole('button', { + name: '画布背景色相', + }); + const spectrumHandle = spectrumButton.querySelector( + '.image-canvas-editor__background-spectrum-handle', + ) as HTMLElement; + const hueHandle = hueButton.querySelector( + '.image-canvas-editor__background-hue-handle', + ) as HTMLElement; + + expect( + spectrumButton.style.getPropertyValue( + '--image-canvas-background-hue-color', + ), + ).toBe('#0080ff'); + expect( + spectrumHandle.style.getPropertyValue( + '--image-canvas-background-spectrum-handle-left', + ), + ).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))'); + expect( + spectrumHandle.style.getPropertyValue( + '--image-canvas-background-spectrum-handle-top', + ), + ).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))'); + expect( + hueHandle.style.getPropertyValue( + '--image-canvas-background-hue-handle-left', + ), + ).toBe( + 'clamp(0.39rem, 58.333333333333336%, calc(100% - 0.39rem))', + ); + + mockElementRect(hueButton, { + left: 0, + top: 0, + width: 100, + height: 20, + }); + fireEvent.pointerDown(hueButton, { + pointerId: 1, + buttons: 1, + clientX: 20, + clientY: 10, + }); + + expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff'); + }); + it('renders zoom and background settings with callback wiring', () => { const props = renderPanelDock({ viewport: { x: 0, y: 0, scale: 0.5 }, + canvasBackgroundColor: '#ff0000', + canvasBackgroundHexValue: '#ff0000', isZoomMenuOpen: true, isBackgroundSettingsOpen: true, }); @@ -122,9 +201,22 @@ describe('ImageCanvasPanelDockView', () => { const panel = screen.getByRole('dialog', { name: '画布背景设置' }); + expect(panel.querySelector('input[type="color"]')).toBeNull(); fireEvent.click(within(panel).getByRole('button', { name: '暖灰' })); - fireEvent.change(within(panel).getByLabelText('自定义画布背景色'), { - target: { value: '#ffffff' }, + const spectrumButton = within(panel).getByRole('button', { + name: '画布背景色盘', + }); + mockElementRect(spectrumButton, { + left: 0, + top: 0, + width: 100, + height: 100, + }); + fireEvent.pointerDown(spectrumButton, { + pointerId: 1, + buttons: 1, + clientX: 50, + clientY: 50, }); fireEvent.change(within(panel).getByLabelText('画布背景十六进制颜色'), { target: { value: '#abc' }, @@ -135,7 +227,7 @@ describe('ImageCanvasPanelDockView', () => { ); expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea'); - expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff'); + expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#804040'); expect(props.onCanvasBackgroundHexChange).toHaveBeenCalledWith('#abc'); expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f8fafc'); expect(props.onToggleBackgroundSettings).toHaveBeenCalledTimes(1); diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 38143ee91..02b04642a 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -8,7 +8,12 @@ import { Undo2, X, } from 'lucide-react'; -import type { PointerEvent as ReactPointerEvent } from 'react'; +import { useEffect, useRef } from 'react'; +import type { + CSSProperties, + KeyboardEvent as ReactKeyboardEvent, + PointerEvent as ReactPointerEvent, +} from 'react'; import { PlatformFloatingMenu, @@ -19,8 +24,10 @@ 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'; @@ -54,6 +61,145 @@ type ImageCanvasPanelDockViewProps = { 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, @@ -81,6 +227,86 @@ export function ImageCanvasPanelDockView({ 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 ( <> {canvasBackgroundColor} -