diff --git a/TRACKING.md b/TRACKING.md index 4c10c893..845181af 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -123,3 +123,4 @@ - 2026-06-17 新增素材持久化修正:素材库图片、上传到画布、生成图、修改图和图标素材加入画布时会先用当前图层快照更新本地画布,再在资源创建完成后立刻保存带真实 `resourceId` 的 layout,避免资源创建异步返回时把空 `layers` 写回工程。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录弹出 `账号入口`,登录后上传素材、点击素材加入画布并刷新,画布图片和 `AI画布工具栏` 均保持可见。 - 2026-06-17 前端拆分第七阶段:新增 `useCanvasHistory`,把画布历史快照、撤销、重做、历史栈长度限制和 `canUndo` / `canRedo` 派生状态从主视图抽出;主视图只在具体动作前捕获历史,并注入恢复快照后的菜单 / hover / 框选 / 拖拽清理。验证命令:`npm run test -- src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 - 2026-06-17 前端拆分第八阶段:新增 `useImageCanvasProjectPersistence`,把项目加载、`projectId` 状态、未就绪资源队列、工程资源创建、资源创建后即时保存和 450ms 自动保存从主视图抽出;新增 hook 单测锁定新增图层资源创建后保存真实 `resourceId` 的 layout。验证命令:`npm run test -- src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`。 +- 2026-06-17 前端拆分第九阶段:新增 `useCanvasGenerationDialogs`,把画布生成对象的 active / inactive 注册表、归档、激活、按 id 更新 / 删除、按图层清理和生成中最新占位框查询从主视图抽出;主视图继续保留生成提交、结果落图、quick edit 和跨图层副作用。同步把 `画布背景设置` 调整为 Lovart 式紧凑色板弹层。验证命令:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录弹出 `账号入口`,关闭后点击 `画布背景色` 显示色域、色相条、圆形预设和 HEX 输入,点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 对话框,`AI画布工具栏` 保持可见。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index cf24d07b..ad240cf7 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -85,6 +85,13 @@ - 该 hook 以“项目持久化协调器”整体抽出,避免把加载、保存和资源创建拆成多个小 hook 后打散 `projectIdRef`、`pendingProjectResourceLayersRef`、`isProjectReady` 和 `saveTimerRef` 的时序约束。 - 主视图继续负责项目重命名 UI、素材库管理、上传流程和用户动作触发;新增图层仍通过 `appendCanvasLayersWithResources` 先写本地图层快照,再创建 project resource 并保存带真实 `resourceId` 的 layout。 +## 第九阶段模块 + +- `useCanvasGenerationDialogs.ts` + - 承载画布生成 dialog 注册表:active / inactive dialog 状态、id 分配、归档、激活、按 id 更新 / 删除、按关联图层清理,以及生成中异步回写时获取最新占位框。 + - 该 hook 只处理 `generate/spec/character/icon` 这类画布生成对象,继续保留 `GenerateDialogState.mode === 'edit'` 的直接 `setGenerateDialog` 能力用于低风险迁移;真实生成请求、quick edit、角色动画、结果落图和资源持久化仍留在主视图。 + - 主视图通过 `onActivate` 处理激活生成对象后的清空图层选择和关闭图片菜单等跨状态副作用,避免 dialog hook 反向依赖画布图层状态。 + ## 后续阶段 - 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index 145051f0..cfac1f31 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -1991,7 +1991,7 @@ describe('ImageCanvasEditorView', () => { const settingsPanel = screen.getByRole('dialog', { name: '画布背景设置', }); - expect(within(settingsPanel).getByText('画布背景')).toBeTruthy(); + expect(within(settingsPanel).getByText('画布背景色')).toBeTruthy(); fireEvent.click( within(settingsPanel).getByRole('button', { name: '暖灰' }), diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 83885b19..f687ba94 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -163,6 +163,7 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; +import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; function isImageFile(file: File) { @@ -244,7 +245,6 @@ export function ImageCanvasEditorView() { const authUiRef = useRef(authUi); const isShiftPressedRef = useRef(false); const layerCounterRef = useRef(0); - const generationDialogCounterRef = useRef(0); const layersRef = useRef([]); const viewportRef = useRef({ x: -260, @@ -256,8 +256,6 @@ export function ImageCanvasEditorView() { const iconSpecButtonRef = useRef(null); const selectedLayerIdRef = useRef(null); const selectedLayerIdsRef = useRef([]); - const generateDialogRef = useRef(null); - const inactiveGenerateDialogsRef = useRef([]); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); @@ -334,11 +332,6 @@ export function ImageCanvasEditorView() { DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); - const [generateDialog, setGenerateDialog] = - useState(null); - const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState< - CanvasGenerationDialogState[] - >([]); const [uploadTarget, setUploadTarget] = useState('asset'); const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); const [ @@ -367,8 +360,6 @@ export function ImageCanvasEditorView() { selectedLayerIdsRef.current = selectedLayerIds; layersRef.current = layers; viewportRef.current = viewport; - generateDialogRef.current = generateDialog; - inactiveGenerateDialogsRef.current = inactiveGenerateDialogs; const assetsRef = useRef(assets); const addAssetLayerRef = useRef< (asset: EditorAsset, screenCenter?: { x: number; y: number }) => void @@ -398,16 +389,29 @@ export function ImageCanvasEditorView() { }, [assets]); const effectiveTool: CanvasTool = isSpacePanning ? 'hand' : activeTool; - const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog) - ? generateDialog - : null; - const canvasGenerationDialogs = useMemo( - () => - activeCanvasGenerationDialog - ? [...inactiveGenerateDialogs, activeCanvasGenerationDialog] - : inactiveGenerateDialogs, - [activeCanvasGenerationDialog, inactiveGenerateDialogs], - ); + const handleActivateCanvasGenerationDialog = useCallback(() => { + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + }, []); + const { + generateDialog, + setGenerateDialog, + generateDialogRef, + inactiveGenerateDialogs, + setInactiveGenerateDialogs, + inactiveGenerateDialogsRef, + activeCanvasGenerationDialog, + canvasGenerationDialogs, + openCanvasGenerationDialog, + updateCanvasGenerationDialogById, + removeCanvasGenerationDialogById, + activateCanvasGenerationDialog, + removeCanvasGenerationDialogsByLayerId, + getGeneratingDialogPlaceholder, + } = useCanvasGenerationDialogs({ + onActivate: handleActivateCanvasGenerationDialog, + }); const selectedLayer = useMemo( () => layers.find((layer) => layer.id === selectedLayerId) ?? null, [layers, selectedLayerId], @@ -612,107 +616,6 @@ export function ImageCanvasEditorView() { selectableAssets.length > 0 && selectableAssets.every((asset) => selectedAssetIds.has(asset.id)); - const createGenerationDialogId = () => { - generationDialogCounterRef.current += 1; - return `generation-dialog-${generationDialogCounterRef.current}`; - }; - - const archiveActiveCanvasGenerationDialog = () => { - const currentDialog = generateDialogRef.current; - if (!isCanvasGenerationDialog(currentDialog)) { - return; - } - setInactiveGenerateDialogs((currentDialogs) => - currentDialogs.some((dialog) => dialog.id === currentDialog.id) - ? currentDialogs - : [ - ...currentDialogs, - { - ...currentDialog, - composerOpen: false, - }, - ], - ); - }; - - const openCanvasGenerationDialog = ( - dialog: Omit, - ) => { - archiveActiveCanvasGenerationDialog(); - setGenerateDialog({ - ...dialog, - id: createGenerationDialogId(), - }); - }; - - const updateCanvasGenerationDialogById = ( - dialogId: string, - updater: ( - dialog: CanvasGenerationDialogState, - ) => CanvasGenerationDialogState | null, - ) => { - setGenerateDialog((currentDialog) => - isCanvasGenerationDialog(currentDialog) && currentDialog.id === dialogId - ? updater(currentDialog) - : currentDialog, - ); - setInactiveGenerateDialogs((currentDialogs) => - currentDialogs.flatMap((dialog) => { - if (dialog.id !== dialogId) { - return [dialog]; - } - const nextDialog = updater(dialog); - return nextDialog ? [nextDialog] : []; - }), - ); - }; - - const removeCanvasGenerationDialogById = (dialogId: string) => { - updateCanvasGenerationDialogById(dialogId, () => null); - }; - - const activateCanvasGenerationDialog = ( - targetDialog: CanvasGenerationDialogState, - ) => { - setInactiveGenerateDialogs((currentDialogs) => { - const nextDialogs = currentDialogs.filter( - (dialog) => dialog.id !== targetDialog.id, - ); - const currentDialog = generateDialogRef.current; - if ( - isCanvasGenerationDialog(currentDialog) && - currentDialog.id !== targetDialog.id - ) { - nextDialogs.push({ - ...currentDialog, - composerOpen: false, - }); - } - return nextDialogs; - }); - setGenerateDialog({ - ...targetDialog, - composerOpen: true, - }); - setSelectedLayerId(null); - setSelectedLayerIds([]); - setImageContextMenu(null); - }; - - const removeCanvasGenerationDialogsByLayerId = (targetLayerId: string) => { - const keepDialog = (dialog: CanvasGenerationDialogState) => - dialog.sourceLayerId !== targetLayerId && - dialog.generatedLayerId !== targetLayerId; - setGenerateDialog((currentDialog) => - isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) - ? null - : currentDialog, - ); - setInactiveGenerateDialogs((currentDialogs) => - currentDialogs.filter(keepDialog), - ); - }; - const selectSingleLayer = useCallback((layerId: string | null) => { setSelectedLayerId(layerId); setSelectedLayerIds(layerId ? [layerId] : []); @@ -781,30 +684,6 @@ export function ImageCanvasEditorView() { setContextMenu(null); }, [hideGeneratedLayerPanelAfterBlur, selectSingleLayer]); - 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; - }, - [], - ); - const minimapModel = useMemo( () => createMinimapModel({ layers, viewport, canvasSize }), [canvasSize, layers, viewport], diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index c4d34f20..5a6a2c95 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -21,6 +21,7 @@ import { Type, Undo2, WandSparkles, + X, } from 'lucide-react'; import type { CSSProperties, @@ -31,7 +32,6 @@ import type { RefObject, } from 'react'; -import { PlatformActionButton } from '../common/PlatformActionButton'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, @@ -40,7 +40,6 @@ import { PlatformIconButton } from '../common/PlatformIconButton'; import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton'; import { PlatformPillBadge } from '../common/PlatformPillBadge'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; -import { PlatformTextField } from '../common/PlatformTextField'; import { EditorIconButton } from './ImageCanvasEditorPrimitives'; import type { StageMinimapModel } from './ImageCanvasInteractionModel'; import { @@ -882,13 +881,44 @@ export function ImageCanvasStageView({ aria-label="画布背景设置" >
- 画布背景 - + 画布背景色 +
+ +
- {option.label} ))}
- -