import { type Dispatch, type SetStateAction, useCallback, useRef, useState, } from 'react'; import type { CanvasClipboard, CanvasContextMenuState, CanvasGenerationDialogState, CanvasHistoryAction, CanvasLayer, } from './ImageCanvasEditorTypes'; import { readLayerImageBlob } from './ImageCanvasExportModel'; import { canCopyCanvasLayers, type CanvasLayerCopyBlockReason, type CanvasLayerFlipAxis, type CanvasLayerMoveMode, createCanvasLayerClipboard, duplicateCanvasLayers, flipCanvasLayers, getCanvasLayerCopyBlockReason, getCanvasLayersByIds, groupCanvasLayers, moveCanvasLayers, removeCanvasLayers, resolveContextTargetLayerIds, toggleCanvasLayersLock, toggleCanvasLayersVisibility, ungroupCanvasLayers, updateCanvasLayersByIds, } from './ImageCanvasLayerCommandModel'; 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>; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; setHoveredLayerId: Dispatch>; setMetadataLayer: Dispatch>; setContextMenu: Dispatch>; setImageContextMenu: (menu: null) => void; setActiveTool: (tool: 'select') => void; captureCanvasHistory: (action: CanvasHistoryAction) => void; selectSingleLayer: (layerId: string | null) => void; onDeleteLayerSideEffects: (targetLayerId: string) => void; onDeleteGenerationDialogSideEffects?: (targetDialogId: string) => void; onRequestDeleteGenerationDialog?: ( targetDialog: CanvasGenerationDialogState, ) => void; exportLayerImage: ( layer: CanvasLayer | null, options?: { mode?: ImageSequenceExportMode }, ) => void; onCanvasLayerCopyBlocked?: (reason: CanvasLayerCopyBlockReason) => void; canDeleteLayers?: (targetLayerIds: string[]) => boolean; }; 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, appendCanvasLayersWithResources, setSelectedLayerId, setSelectedLayerIds, setHoveredLayerId, setMetadataLayer, setContextMenu, setImageContextMenu, setActiveTool, captureCanvasHistory, selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, onRequestDeleteGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked, canDeleteLayers, }: LayerCommandsOptions) { const [canvasClipboard, setCanvasClipboard] = useState(null); const canvasClipboardRef = useRef(null); const duplicateLayerSequenceRef = useRef(0); 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 } = {}, ) => { duplicateLayerSequenceRef.current += 1; return duplicateCanvasLayers({ sourceLayers, allLayers: layers, canvasPoint, renameCopies: options.renameCopies !== false, stamp: `${Date.now()}-${duplicateLayerSequenceRef.current}`, }); }, [layers], ); const rejectPendingResourceCopy = useCallback( (targetLayers: CanvasLayer[]) => { const reason = getCanvasLayerCopyBlockReason(targetLayers); if (!reason) { return false; } if (targetLayers.length) { onCanvasLayerCopyBlocked?.(reason); } return true; }, [onCanvasLayerCopyBlocked], ); const pasteCanvasClipboard = useCallback( (canvasPoint?: { x: number; y: number }) => { const clipboard = canvasClipboardRef.current; if (!clipboard?.layers.length) { return false; } if (rejectPendingResourceCopy(clipboard.layers)) { return true; } const nextLayers = duplicateLayersToPoint(clipboard.layers, canvasPoint, { renameCopies: clipboard.mode !== 'cut', }); if (!nextLayers.length) { return false; } captureCanvasHistory({ type: 'paste-image', count: nextLayers.length }); appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setActiveTool('select'); closeContextMenus(); return true; }, [ captureCanvasHistory, closeContextMenus, duplicateLayersToPoint, appendCanvasLayersWithResources, rejectPendingResourceCopy, setActiveTool, setSelectedLayerId, setSelectedLayerIds, ], ); const copyContextLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); if (rejectPendingResourceCopy(targetLayers)) { return; } if (options.cut && canDeleteLayers?.(targetIds) === false) { closeContextMenus(); return; } 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({ type: 'cut-image', count: targetIds.length }); setLayers((currentLayers) => removeCanvasLayers(currentLayers, targetIds), ); selectSingleLayer(null); setMetadataLayer((currentLayer) => currentLayer && targetIds.includes(currentLayer.id) ? null : currentLayer, ); targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId)); } closeContextMenus(); }, [ captureCanvasHistory, canDeleteLayers, closeContextMenus, getContextTargetLayerIds, layers, onDeleteLayerSideEffects, rejectPendingResourceCopy, selectSingleLayer, setLayers, setMetadataLayer, ], ); const copySelectedLayers = useCallback( (options: { cut?: boolean } = {}) => { const targetIds = getSelectedTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); if (rejectPendingResourceCopy(targetLayers)) { return; } if (options.cut && canDeleteLayers?.(targetIds) === false) { setContextMenu(null); setImageContextMenu(null); return; } 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({ type: 'cut-image', count: targetIds.length }); 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, canDeleteLayers, getSelectedTargetLayerIds, layers, onDeleteLayerSideEffects, rejectPendingResourceCopy, selectSingleLayer, setContextMenu, setImageContextMenu, setLayers, setMetadataLayer, ], ); const duplicateContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); if (rejectPendingResourceCopy(targetLayers)) { return; } const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; } captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, duplicateLayersToPoint, getContextTargetLayerIds, layers, appendCanvasLayersWithResources, rejectPendingResourceCopy, setSelectedLayerId, setSelectedLayerIds, ]); const duplicateSelectedLayers = useCallback(() => { const targetIds = getSelectedTargetLayerIds(); const targetLayers = getCanvasLayersByIds(layers, targetIds); if (rejectPendingResourceCopy(targetLayers)) { return; } const nextLayers = duplicateLayersToPoint(targetLayers); if (!nextLayers.length) { return; } captureCanvasHistory({ type: 'duplicate-image', count: nextLayers.length }); appendCanvasLayersWithResources(nextLayers); setSelectedLayerIds(nextLayers.map((layer) => layer.id)); setSelectedLayerId(nextLayers[0]?.id ?? null); setContextMenu(null); setImageContextMenu(null); }, [ captureCanvasHistory, duplicateLayersToPoint, getSelectedTargetLayerIds, layers, appendCanvasLayersWithResources, rejectPendingResourceCopy, setContextMenu, setImageContextMenu, setSelectedLayerId, setSelectedLayerIds, ]); const updateContextLayers = useCallback( (updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length, layerIds: targetIds, }); setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const moveContextLayers = useCallback( (mode: CanvasLayerMoveMode) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory({ type: 'change-layer-order', count: targetIds.length, }); setLayers((currentLayers) => moveCanvasLayers(currentLayers, targetIds, mode), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const moveSelectedLayers = useCallback( (mode: CanvasLayerMoveMode) => { const targetIds = getSelectedTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory({ type: 'change-layer-order', count: targetIds.length, }); 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({ type: 'group-images', count: targetIds.length }); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetIds, createGroupId()), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const ungroupContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory({ type: 'ungroup-images', count: targetIds.length }); setLayers((currentLayers) => ungroupCanvasLayers(currentLayers, targetIds)); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ]); const toggleContextLayerVisibility = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } const shouldHide = getCanvasLayersByIds(layers, targetIds).some( (layer) => !layer.hidden, ); captureCanvasHistory({ type: shouldHide ? 'hide-image' : 'show-image', count: targetIds.length, }); setLayers((currentLayers) => toggleCanvasLayersVisibility(currentLayers, targetIds), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, layers, setLayers, ]); const toggleContextLayerLock = useCallback(() => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } const shouldLock = getCanvasLayersByIds(layers, targetIds).some( (layer) => !layer.locked, ); captureCanvasHistory({ type: shouldLock ? 'lock-image' : 'unlock-image', count: targetIds.length, }); setLayers((currentLayers) => toggleCanvasLayersLock(currentLayers, targetIds), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, layers, setLayers, ]); const flipContextLayers = useCallback( (axis: CanvasLayerFlipAxis) => { const targetIds = getContextTargetLayerIds(); if (!targetIds.length) { return; } captureCanvasHistory({ type: 'flip-image', count: targetIds.length }); setLayers((currentLayers) => flipCanvasLayers(currentLayers, targetIds, axis), ); closeContextMenus(); }, [ captureCanvasHistory, closeContextMenus, getContextTargetLayerIds, setLayers, ], ); const deleteContextLayers = useCallback(() => { const targetIds = getContextTargetLayerIds(); const targetLayerIds = getSelectedLayerIds(targetIds); const targetDialogs = targetIds .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) .flatMap((dialogId) => { const dialog = canvasGenerationDialogs.find( (candidate) => candidate.id === dialogId, ); return dialog ? [dialog] : []; }); if ( !targetLayerIds.length && targetDialogs.length && onRequestDeleteGenerationDialog ) { closeContextMenus(); targetDialogs.forEach((dialog) => { onRequestDeleteGenerationDialog(dialog); }); return; } const targetDialogIds = targetDialogs.map((dialog) => dialog.id); if (!targetLayerIds.length && !targetDialogIds.length) { return; } if (targetLayerIds.length && canDeleteLayers?.(targetLayerIds) === false) { closeContextMenus(); return; } captureCanvasHistory({ type: targetLayerIds.length ? 'delete-image' : 'delete-generation-result', count: targetLayerIds.length || targetDialogIds.length, }); 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, canDeleteLayers, canvasGenerationDialogs, closeContextMenus, getContextTargetLayerIds, onDeleteGenerationDialogSideEffects, onDeleteLayerSideEffects, onRequestDeleteGenerationDialog, 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; } if (canDeleteLayers?.([targetLayerId]) === false) { setImageContextMenu(null); setContextMenu(null); return; } setImageContextMenu(null); setContextMenu(null); captureCanvasHistory({ type: 'delete-image', count: 1 }); 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, canDeleteLayers, 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 && canDeleteLayers?.(targetLayerIds) === false) { setImageContextMenu(null); setContextMenu(null); return; } if (targetLayerIds.length === 1 && !targetDialogIds.length) { deleteLayerById(targetLayerIds[0] ?? null); return; } captureCanvasHistory({ type: targetLayerIds.length ? 'delete-image' : 'delete-generation-result', count: targetLayerIds.length || targetDialogIds.length, }); 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, canDeleteLayers, 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({ type: 'group-images', count: targetLayerIds.length, }); setLayers((currentLayers) => groupCanvasLayers(currentLayers, targetLayerIds, createGroupId()), ); }, [captureCanvasHistory, selectedLayerId, selectedLayerIds, setLayers]); const contextTargetLayers = getCanvasLayersByIds( layers, getContextTargetLayerIds(), ); return { canvasClipboard, canCopyContextLayers: canCopyCanvasLayers(contextTargetLayers), contextLayerCopyBlockReason: getCanvasLayerCopyBlockReason(contextTargetLayers), isCanvasClipboardCopyBlocked: Boolean( canvasClipboard && !canCopyCanvasLayers(canvasClipboard.layers), ), getContextTargetLayerIds, pasteCanvasClipboard, copyContextLayers, copySelectedLayers, duplicateContextLayers, duplicateSelectedLayers, updateContextLayers, moveContextLayers, moveSelectedLayers, groupContextLayers, ungroupContextLayers, toggleContextLayerVisibility, toggleContextLayerLock, flipContextLayers, deleteContextLayers, exportContextLayer, deleteLayerById, deleteSelectedLayer, groupSelectedLayers, }; }