import { type Dispatch, type SetStateAction, useCallback, useRef, useState, } from 'react'; import type { CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, CanvasLayer, } from './ImageCanvasEditorTypes'; import { type CanvasLayerFlipAxis, type CanvasLayerMoveMode, createCanvasLayerClipboard, duplicateCanvasLayers, flipCanvasLayers, getCanvasLayersByIds, groupCanvasLayers, moveCanvasLayers, removeCanvasLayers, resolveContextTargetLayerIds, toggleCanvasLayersLock, toggleCanvasLayersVisibility, ungroupCanvasLayers, updateCanvasLayersByIds, } from './ImageCanvasLayerCommandModel'; import { readLayerImageBlob } from './ImageCanvasExportModel'; import { getCanvasGenerationDialogIdFromSelectionId, getSelectedLayerIds, } from './ImageCanvasSelectionModel'; import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow'; type LayerCommandsOptions = { layers: CanvasLayer[]; contextMenu: CanvasContextMenuState | null; selectedLayerId: string | null; selectedLayerIds: string[]; canvasGenerationDialogs?: CanvasGenerationDialogState[]; setLayers: Dispatch>; setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; setHoveredLayerId: Dispatch>; setMetadataLayer: Dispatch>; setContextMenu: Dispatch>; setImageContextMenu: (menu: null) => void; setActiveTool: (tool: 'select') => void; captureCanvasHistory: () => void; selectSingleLayer: (layerId: string | null) => void; onDeleteLayerSideEffects: (targetLayerId: string) => void; onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void; exportLayerImage: ( layer: CanvasLayer | null, options?: { mode?: ImageSequenceExportMode }, ) => void; }; function createGroupId() { return `layer-group-${Date.now()}`; } function isClipboardImageLayer(layer: CanvasLayer) { return ( Boolean(layer.src.trim()) && layer.mediaType !== 'video' && layer.mediaType !== 'audio' && layer.mediaType !== 'image-sequence' ); } async function normalizeClipboardImageBlob(blob: Blob) { if (blob.type === 'image/png') { return blob; } if (typeof createImageBitmap === 'function') { try { const image = await createImageBitmap(blob); const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height; canvas.getContext('2d')?.drawImage(image, 0, 0); const pngBlob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'), ); if (pngBlob) { return pngBlob; } } catch { // 浏览器不支持当前图片解码时,回退到原始字节。 } } if (!blob.type || blob.type === 'application/octet-stream') { return blob.slice(0, blob.size, 'image/png'); } return blob; } async function readLayerClipboardPngBlob(layer: CanvasLayer) { const blob = await normalizeClipboardImageBlob( await readLayerImageBlob(layer), ); return blob.type === 'image/png' ? blob : blob.slice(0, blob.size, 'image/png'); } function copyLayerImageToSystemClipboard(layers: CanvasLayer[]) { const layer = layers.slice().reverse().find(isClipboardImageLayer); const clipboard = globalThis.navigator?.clipboard; const ClipboardItemCtor = globalThis.ClipboardItem; if (!layer || !clipboard?.write || typeof ClipboardItemCtor !== 'function') { return; } void clipboard.write([ new ClipboardItemCtor({ 'image/png': readLayerClipboardPngBlob(layer), }), ]).catch(() => { // 系统剪贴板只是画布内复制的附加能力,失败不阻断内部粘贴。 }); } export function useImageCanvasLayerCommands({ layers, contextMenu, selectedLayerId, selectedLayerIds, canvasGenerationDialogs = [], setLayers, setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, setMetadataLayer, setContextMenu, setImageContextMenu, setActiveTool, captureCanvasHistory, selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, exportLayerImage, }: LayerCommandsOptions) { const [canvasClipboard, setCanvasClipboard] = useState(null); const canvasClipboardRef = useRef(null); const closeContextMenus = useCallback(() => { setContextMenu(null); setImageContextMenu(null); }, [setContextMenu, setImageContextMenu]); const getContextTargetLayerIds = useCallback( (menu: CanvasContextMenuState | null = contextMenu) => resolveContextTargetLayerIds(menu, selectedLayerIds), [contextMenu, selectedLayerIds], ); const getSelectedTargetLayerIds = useCallback(() => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; return getSelectedLayerIds(targetIds); }, [selectedLayerId, selectedLayerIds]); const duplicateLayersToPoint = useCallback( ( sourceLayers: CanvasLayer[], canvasPoint?: { x: number; y: number }, options: { renameCopies?: boolean } = {}, ) => duplicateCanvasLayers({ sourceLayers, allLayers: layers, canvasPoint, renameCopies: options.renameCopies !== false, }), [layers], ); const pasteCanvasClipboard = useCallback( (canvasPoint?: { x: number; y: number }) => { const clipboard = canvasClipboardRef.current; if (!clipboard?.layers.length) { return false; } const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, { renameCopies: clipboard.mode !== 'cut', }); if (!nextLayers.length) { return false; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); closeContextMenus(); return true; }, [ captureCanvasHistory, closeContextMenus, duplicateLayersToPoint, setActiveTool, setLayers, setSelectedLayerId, setSelectedLayerIds, ], ); const copyContextLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getContextTargetLayerIds(); const clipboard = createCanvasLayerClipboard( layers, targetIds, options.cut ? 'cut' : 'copy', ); if (!clipboard) { return; } canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); if (!options.cut) { copyLayerImageToSystemClipboard(clipboard.layers); } if (options.cut) { captureCanvasHistory(); setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds), ); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); } closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, layers, selectSingleLayer, setLayers, setMetadataLayer, ], ); const copySelectedLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getSelectedTargetLayerIds(); const clipboard = createCanvasLayerClipboard( layers, targetIds, options.cut ? 'cut' : 'copy', ); if (!clipboard) { return; } canvasClipboardRef.current = clipboard; setCanvasClipboard(clipboard); if (!options.cut) { copyLayerImageToSystemClipboard(clipboard.layers); } if (options.cut) { captureCanvasHistory(); setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds), ); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); } setContextMenu(null); setImageContextMenu(null); }, [ captureCanvasHistory, getSelectedTargetLayerIds, layers, onDeleteLayerSideEffects, selectSingleLayer, setContextMenu, setImageContextMenu, setLayers, setMetadataLayer, ], ); const duplicateContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, duplicateLayersToPoint, getContextTargetLayerIds, layers, setLayers, setSelectedLayerId, setSelectedLayerIds, ]); const duplicateSelectedLayers = useCallback(() => { const targetIds = getSelectedTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setContextMenu(null); setImageContextMenu(null); }, [ captureCanvasHistory, duplicateLayersToPoint, getSelectedTargetLayerIds, layers, setContextMenu, setImageContextMenu, setLayers, setSelectedLayerId, setSelectedLayerIds, ]); const updateContextLayers = useCallback( (updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const moveContextLayers = useCallback( (mode: CanvasLayerMoveMode) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const moveSelectedLayers = useCallback( (mode: CanvasLayerMoveMode) => { const targetIds = getSelectedTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); setContextMenu(null); setImageContextMenu(null); }, [ captureCanvasHistory, getSelectedTargetLayerIds, setContextMenu, setImageContextMenu, setLayers, ], ); const groupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetIds, createGroupId()), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const ungroupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const toggleContextLayerVisibility = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => toggleCanvasLayersVisibility(currentLayers, targetIds), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const toggleContextLayerLock = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => toggleCanvasLayersLock(currentLayers, targetIds), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const flipContextLayers = useCallback( (axis: CanvasLayerFlipAxis) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => flipCanvasLayers(currentLayers, targetIds, axis), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const deleteContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayerIds = getSelectedLayerIds(targetIds); const targetDialogIds = targetIds .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) .filter((dialogId) => canvasGenerationDialogs.some((dialog) => dialog.id === dialogId), ); if (!targetLayerIds.length && !targetDialogIds.length) { return; } captureCanvasHistory(); if (targetLayerIds.length) { setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetLayerIds), ); } selectSingleLayer(null); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer && targetLayerIds.includes(currentLayer.id) ? null : currentLayer, ); targetLayerIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); targetDialogIds.forEach((targetId) => onDeleteGenerationDialogSideEffects?.(targetId), ); closeContextMenus(); }, [ captureCanvasHistory, canvasGenerationDialogs, closeContextMenus, getContextTargetLayerIds, onDeleteGenerationDialogSideEffects, onDeleteLayerSideEffects, selectSingleLayer, setHoveredLayerId, setLayers, setMetadataLayer, ]); const exportContextLayer = useCallback( (options: { mode?: ImageSequenceExportMode } = {}) => { const targetIds = getContextTargetLayerIds(); const targetLayer = layers.find((layer) => targetIds.includes(layer.id)); exportLayerImage(targetLayer ?? null, options); setContextMenu(null); setImageContextMenu(null); }, [ exportLayerImage, getContextTargetLayerIds, layers, setContextMenu, setImageContextMenu, ], ); const deleteLayerById = useCallback( (targetLayerId: string | null) => { if (!targetLayerId) { return; } setImageContextMenu(null); setContextMenu(null); captureCanvasHistory(); setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => layer.id !== targetLayerId, ); const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; selectSingleLayer(nextSelectedLayer?.id ?? null); return nextLayers; }); setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer?.id === targetLayerId ? null : currentLayer, ); onDeleteLayerSideEffects(targetLayerId); }, [ captureCanvasHistory, onDeleteLayerSideEffects, selectSingleLayer, setContextMenu, setHoveredLayerId, setImageContextMenu, setLayers, setMetadataLayer, ], ); const deleteSelectedLayer = useCallback(() => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; const targetLayerIds = getSelectedLayerIds(targetIds); const targetDialogIds = targetIds .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) .filter((dialogId) => canvasGenerationDialogs.some((dialog) => dialog.id === dialogId), ); if (!targetLayerIds.length && !targetDialogIds.length) { return; } if (targetLayerIds.length === 1 && !targetDialogIds.length) { deleteLayerById(targetLayerIds[0] ?? null); return; } captureCanvasHistory(); setImageContextMenu(null); setContextMenu(null); if (targetLayerIds.length) { setLayers((currentLayers) => { const nextLayers = currentLayers.filter( (layer) => !targetLayerIds.includes(layer.id), ); if (!targetDialogIds.length) { const nextSelectedLayer = nextLayers .slice() .sort((left, right) => right.zIndex - left.zIndex)[0]; selectSingleLayer(nextSelectedLayer?.id ?? null); } return nextLayers; }); } if (targetDialogIds.length || !targetLayerIds.length) { selectSingleLayer(null); } setHoveredLayerId(null); setMetadataLayer((currentLayer) => currentLayer && targetLayerIds.includes(currentLayer.id) ? null : currentLayer, ); targetLayerIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); targetDialogIds.forEach((targetId) => onDeleteGenerationDialogSideEffects?.(targetId), ); }, [ captureCanvasHistory, canvasGenerationDialogs, deleteLayerById, onDeleteGenerationDialogSideEffects, onDeleteLayerSideEffects, selectSingleLayer, selectedLayerId, selectedLayerIds, setContextMenu, setHoveredLayerId, setImageContextMenu, setLayers, setMetadataLayer, ]); const groupSelectedLayers = useCallback(() => { const targetIds = selectedLayerIds.length ? selectedLayerIds : selectedLayerId ? [selectedLayerId] : []; const targetLayerIds = getSelectedLayerIds(targetIds); if (!targetLayerIds.length) { return; } captureCanvasHistory(); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetLayerIds, createGroupId()), ); }, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]); return { canvasClipboard, getContextTargetLayerIds, pasteCanvasClipboard, copyContextLayers, copySelectedLayers, duplicateContextLayers, duplicateSelectedLayers, updateContextLayers, moveContextLayers, moveSelectedLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, toggleContextLayerLock, flipContextLayers, deleteContextLayers, exportContextLayer, deleteLayerById, deleteSelectedLayer, groupSelectedLayers, }; }