import { type Dispatch, type SetStateAction, useCallback, useMemo, useRef, useState, } from 'react'; import type { CanvasGenerationDialogState, GenerateDialogState, } from './ImageCanvasEditorTypes'; import { isCanvasGenerationDialog } from './ImageCanvasGenerationModel'; type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; export type CanvasGenerationDialogDraft = Omit< CanvasGenerationDialogState, 'id' > & { id?: string; }; /** * 中文注释:删除生成占位前是否需要二次确认。 * * 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素 * `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常 * 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。 * 所以完美像素占位在任何状态都直接删,不额外解释。 * * 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 / * 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm), * 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点 * 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。 * * 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。 * 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。 */ export function requiresGenerationDeleteConfirmation( dialog: CanvasGenerationDialogState, ) { return dialog.status === 'generating' && !dialog.perfectPixelOperationId; } function withGenerationTimestamps( nextDialog: T, previousDialog?: GenerateDialogState | null, ): T { if (!nextDialog) { return nextDialog; } if (nextDialog.status !== 'generating') { const shouldMarkFinished = nextDialog.status === 'failed' || Boolean(nextDialog.generatedLayerId); return { ...nextDialog, generationFinishedAt: shouldMarkFinished ? (nextDialog.generationFinishedAt ?? previousDialog?.generationFinishedAt ?? Date.now()) : nextDialog.generationFinishedAt, }; } return { ...nextDialog, generationStartedAt: nextDialog.generationStartedAt ?? previousDialog?.generationStartedAt ?? Date.now(), generationFinishedAt: undefined, }; } function generationDialogContextId(dialog: GenerateDialogState | null) { if (!dialog) { return null; } return isCanvasGenerationDialog(dialog) ? dialog.id : `${dialog.mode}:${dialog.sourceLayerId ?? 'draft'}`; } export function useCanvasGenerationDialogs({ onActivate, isContextMutationLocked, onContextMutationRejected, }: { onActivate?: () => void; isContextMutationLocked?: () => boolean; onContextMutationRejected?: () => void; } = {}) { const generationDialogCounterRef = useRef(0); const generateDialogRef = useRef(null); const inactiveGenerateDialogsRef = useRef([]); const [generateDialog, setGenerateDialogState] = useState(null); const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< CanvasGenerationDialogState[] >([]); generateDialogRef.current = generateDialog; inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog) ? generateDialog : null; const rejectContextMutation = useCallback(() => { if (!isContextMutationLocked?.()) { return false; } onContextMutationRejected?.(); return true; }, [isContextMutationLocked, onContextMutationRejected]); const setGenerateDialog = useCallback< Dispatch> >( (nextDialogOrUpdater) => { const currentDialog = generateDialogRef.current; const nextDialog = typeof nextDialogOrUpdater === 'function' ? nextDialogOrUpdater(currentDialog) : nextDialogOrUpdater; const nextDialogWithTimestamps = withGenerationTimestamps( nextDialog, currentDialog, ); const currentContextId = generationDialogContextId(currentDialog); const nextContextId = generationDialogContextId(nextDialogWithTimestamps); if (currentContextId !== nextContextId && rejectContextMutation()) { return; } generateDialogRef.current = nextDialogWithTimestamps; setGenerateDialogState(nextDialogWithTimestamps); }, [rejectContextMutation], ); const canvasGenerationDialogs = useMemo( () => activeCanvasGenerationDialog ? [...inactiveGenerateDialogs, activeCanvasGenerationDialog] : inactiveGenerateDialogs, [activeCanvasGenerationDialog, inactiveGenerateDialogs], ); const getCanvasGenerationDialogsSnapshot = useCallback(() => { const currentDialog = generateDialogRef.current; return [ ...inactiveGenerateDialogsRef.current, ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), ]; }, []); const createGenerationDialogId = useCallback(() => { generationDialogCounterRef.current += 1; return `generation-dialog-${generationDialogCounterRef.current}`; }, []); const archiveActiveCanvasGenerationDialog = useCallback(() => { const currentDialog = generateDialogRef.current; if (!isCanvasGenerationDialog(currentDialog)) { return; } if (rejectContextMutation()) { return; } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some( (dialog) => dialog.id === currentDialog.id, ) ? inactiveGenerateDialogsRef.current : [ ...inactiveGenerateDialogsRef.current, { ...currentDialog, composerOpen: false, }, ]; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setInactiveGenerateDialogs(nextInactiveDialogs); }, [rejectContextMutation]); const openCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogDraft) => { const currentDialog = generateDialogRef.current; if (rejectContextMutation()) { return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : ''; } if (isCanvasGenerationDialog(currentDialog)) { inactiveGenerateDialogsRef.current = inactiveGenerateDialogsRef.current.some( (existingDialog) => existingDialog.id === currentDialog.id, ) ? inactiveGenerateDialogsRef.current : [ ...inactiveGenerateDialogsRef.current, { ...currentDialog, composerOpen: false, }, ]; } archiveActiveCanvasGenerationDialog(); const requestedId = dialog.id?.trim(); const requestedIdAlreadyExists = Boolean(requestedId) && getCanvasGenerationDialogsSnapshot().some( (existingDialog) => existingDialog.id === requestedId, ); const id = requestedId && !requestedIdAlreadyExists ? requestedId : createGenerationDialogId(); const { id: _requestedId, ...dialogWithoutId } = dialog; const nextDialog = withGenerationTimestamps({ ...dialogWithoutId, id, }); generateDialogRef.current = nextDialog; setGenerateDialogState(nextDialog); return id; }, [ archiveActiveCanvasGenerationDialog, createGenerationDialogId, getCanvasGenerationDialogsSnapshot, rejectContextMutation, ], ); const updateCanvasGenerationDialogById = useCallback( (dialogId: string, updater: CanvasGenerationDialogUpdater) => { const currentDialogRef = generateDialogRef.current; if ( isCanvasGenerationDialog(currentDialogRef) && currentDialogRef.id === dialogId && updater(currentDialogRef) === null && rejectContextMutation() ) { return; } if ( isCanvasGenerationDialog(currentDialogRef) && currentDialogRef.id === dialogId ) { generateDialogRef.current = withGenerationTimestamps( updater(currentDialogRef), currentDialogRef, ); } inactiveGenerateDialogsRef.current = inactiveGenerateDialogsRef.current.flatMap((dialog) => { if (dialog.id !== dialogId) { return [dialog]; } const nextDialog = withGenerationTimestamps(updater(dialog), dialog); return nextDialog ? [nextDialog] : []; }); setGenerateDialogState((currentDialog) => { if ( !isCanvasGenerationDialog(currentDialog) || currentDialog.id !== dialogId ) { return currentDialog; } return withGenerationTimestamps(updater(currentDialog), currentDialog); }); setInactiveGenerateDialogs((currentDialogs) => currentDialogs.flatMap((dialog) => { if (dialog.id !== dialogId) { return [dialog]; } const nextDialog = withGenerationTimestamps(updater(dialog), dialog); return nextDialog ? [nextDialog] : []; }), ); }, [rejectContextMutation], ); // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 // ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在 // 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利, // 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。 const removeCanvasGenerationDialogById = useCallback( (dialogId: string) => { updateCanvasGenerationDialogById(dialogId, () => null); }, [updateCanvasGenerationDialogById], ); const hasCanvasGenerationDialogById = useCallback((dialogId: string) => { const currentDialog = generateDialogRef.current; return [ ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), ...inactiveGenerateDialogsRef.current, ].some((dialog) => dialog.id === dialogId); }, []); const getCanvasGenerationDialogById = useCallback((dialogId: string) => { const currentDialog = generateDialogRef.current; if ( isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId ) { return currentDialog; } return inactiveGenerateDialogsRef.current.find( (dialog) => dialog.id === dialogId, ); }, []); const activateCanvasGenerationDialog = useCallback( (targetDialog: CanvasGenerationDialogState) => { const currentDialog = generateDialogRef.current; if ( (!isCanvasGenerationDialog(currentDialog) || currentDialog.id !== targetDialog.id) && rejectContextMutation() ) { return; } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter( (dialog) => dialog.id !== targetDialog.id, ); if ( isCanvasGenerationDialog(currentDialog) && currentDialog.id !== targetDialog.id ) { nextInactiveDialogs.push({ ...currentDialog, composerOpen: false, }); } const nextActiveDialog = { ...targetDialog, composerOpen: true, }; inactiveGenerateDialogsRef.current = nextInactiveDialogs; generateDialogRef.current = nextActiveDialog; setInactiveGenerateDialogs(nextInactiveDialogs); setGenerateDialogState(nextActiveDialog); onActivate?.(); }, [onActivate, rejectContextMutation], ); const restoreCanvasGenerationDialogs = useCallback( (dialogs: CanvasGenerationDialogState[]) => { const nextCounter = dialogs.reduce((maxCounter, dialog) => { const match = /^generation-dialog-(\d+)$/u.exec(dialog.id); const numericId = match ? Number.parseInt(match[1] ?? '0', 10) : 0; return Math.max(maxCounter, Number.isFinite(numericId) ? numericId : 0); }, 0); generationDialogCounterRef.current = Math.max( generationDialogCounterRef.current, nextCounter, ); const activeDialog = [...dialogs] .reverse() .find((dialog) => dialog.composerOpen !== false) ?? dialogs[dialogs.length - 1] ?? null; const nextActiveDialog = activeDialog ? { ...activeDialog, composerOpen: activeDialog.composerOpen !== false, } : null; const nextInactiveDialogs = dialogs .filter((dialog) => dialog.id !== activeDialog?.id) .map((dialog) => ({ ...dialog, composerOpen: false, })); generateDialogRef.current = nextActiveDialog; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setGenerateDialogState(nextActiveDialog); setInactiveGenerateDialogs(nextInactiveDialogs); }, [], ); const removeCanvasGenerationDialogsByLayerId = useCallback( (targetLayerId: string) => { const keepDialog = (dialog: CanvasGenerationDialogState) => dialog.sourceLayerId !== targetLayerId && dialog.generatedLayerId !== targetLayerId; const currentDialog = generateDialogRef.current; if ( isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) && rejectContextMutation() ) { return; } const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) ? null : currentDialog; const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter(keepDialog); generateDialogRef.current = nextActiveDialog; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setGenerateDialogState(nextActiveDialog); setInactiveGenerateDialogs(nextInactiveDialogs); }, [rejectContextMutation], ); const getGeneratingDialogPlaceholder = useCallback( (dialog: GenerateDialogState) => { const currentDialog = generateDialogRef.current; if (dialog.id) { const latestDialog = [ ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), ...inactiveGenerateDialogsRef.current, ].find((candidateDialog) => candidateDialog.id === dialog.id); if (latestDialog?.status === 'generating') { return latestDialog.placeholder ?? dialog.placeholder; } } if ( currentDialog?.mode === dialog.mode && (!dialog.id || currentDialog.id === dialog.id) && currentDialog.status === 'generating' ) { return currentDialog.placeholder ?? dialog.placeholder; } return dialog.placeholder; }, [], ); return { generateDialog, setGenerateDialog, generateDialogRef, inactiveGenerateDialogs, setInactiveGenerateDialogs, inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, getCanvasGenerationDialogsSnapshot, archiveActiveCanvasGenerationDialog, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, hasCanvasGenerationDialogById, getCanvasGenerationDialogById, activateCanvasGenerationDialog, restoreCanvasGenerationDialogs, removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder, }; }