拆分图片画布生成对象注册表

新增画布生成对象 dialog 管理 hook
补充生成对象注册表 hook 单测
调整 Lovart 式画布背景色板弹层
更新图片画布前端拆分跟踪文档
This commit is contained in:
2026-06-17 05:29:04 +08:00
parent 9f45641ccd
commit 37a738e271
8 changed files with 595 additions and 220 deletions

View File

@@ -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: '暖灰' }),

View File

@@ -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<CanvasLayer[]>([]);
const viewportRef = useRef<CanvasViewport>({
x: -260,
@@ -256,8 +256,6 @@ export function ImageCanvasEditorView() {
const iconSpecButtonRef = useRef<HTMLButtonElement | null>(null);
const selectedLayerIdRef = useRef<string | null>(null);
const selectedLayerIdsRef = useRef<string[]>([]);
const generateDialogRef = useRef<GenerateDialogState | null>(null);
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
() => {},
);
@@ -334,11 +332,6 @@ export function ImageCanvasEditorView() {
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
CanvasGenerationDialogState[]
>([]);
const [uploadTarget, setUploadTarget] = useState<UploadTarget>('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<CanvasGenerationDialogState, 'id'>,
) => {
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],

View File

@@ -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="画布背景设置"
>
<div className="image-canvas-editor__background-panel-head">
<span></span>
<span
className="image-canvas-editor__background-preview"
aria-label={`当前画布背景色 ${canvasBackgroundColor}`}
style={{ backgroundColor: canvasBackgroundColor }}
/>
<span></span>
<button
type="button"
className="image-canvas-editor__background-close"
aria-label="关闭画布背景设置"
onClick={onToggleBackgroundSettings}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<label className="image-canvas-editor__background-spectrum">
<input
type="color"
aria-label="画布背景色相"
value={canvasBackgroundColor}
onChange={(event) =>
onApplyCanvasBackgroundColor(event.currentTarget.value)
}
/>
<span
className="image-canvas-editor__background-spectrum-surface"
aria-hidden="true"
/>
<span
className="image-canvas-editor__background-spectrum-handle"
aria-hidden="true"
/>
</label>
<label className="image-canvas-editor__background-hue">
<input
type="color"
aria-label="自定义画布背景色"
value={canvasBackgroundColor}
onChange={(event) =>
onApplyCanvasBackgroundColor(event.currentTarget.value)
}
/>
</label>
<div
className="image-canvas-editor__background-presets"
aria-label="画布背景预设色"
@@ -906,37 +936,22 @@ export function ImageCanvasStageView({
className="image-canvas-editor__background-swatch"
style={{ backgroundColor: option.value }}
/>
<span>{option.label}</span>
</button>
))}
</div>
<label className="image-canvas-editor__background-field">
<span></span>
<input
type="color"
aria-label="自定义画布背景色"
value={canvasBackgroundColor}
onChange={(event) =>
onApplyCanvasBackgroundColor(event.currentTarget.value)
}
/>
</label>
<label className="image-canvas-editor__background-field image-canvas-editor__background-field--hex">
<label className="image-canvas-editor__background-hex-field">
<span>HEX</span>
<PlatformTextField
<input
aria-label="画布背景十六进制颜色"
value={canvasBackgroundHexValue}
density="compact"
size="xs"
spellCheck={false}
onChange={(event) =>
onCanvasBackgroundHexChange(event.currentTarget.value)
}
/>
</label>
<PlatformActionButton
tone="secondary"
size="xs"
<button
type="button"
className="image-canvas-editor__background-reset"
onClick={() =>
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
@@ -944,7 +959,7 @@ export function ImageCanvasStageView({
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
</PlatformActionButton>
</button>
</div>
) : null}
</div>

View File

@@ -0,0 +1,177 @@
/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
function createDialog(
mode: CanvasGenerationDialogState['mode'],
prompt: string,
): Omit<CanvasGenerationDialogState, 'id'> {
return {
mode,
prompt,
status: 'idle',
composerOpen: true,
placeholder: {
x: mode === 'character' ? 30 : 10,
y: mode === 'character' ? 40 : 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
};
}
function GenerationDialogsHarness({ onActivate }: { onActivate: () => void }) {
const dialogs = useCanvasGenerationDialogs({ onActivate });
const activeId = dialogs.activeCanvasGenerationDialog?.id ?? '-';
const activePrompt = dialogs.activeCanvasGenerationDialog?.prompt ?? '-';
const inactiveIds = dialogs.inactiveGenerateDialogs
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.composerOpen}`)
.join('|');
const allPrompts = dialogs.canvasGenerationDialogs
.map((dialog) => dialog.prompt)
.join(',');
return (
<div>
<span data-testid="active-id">{activeId}</span>
<span data-testid="active-prompt">{activePrompt}</span>
<span data-testid="inactive">{inactiveIds}</span>
<span data-testid="all-prompts">{allPrompts}</span>
<span data-testid="placeholder-x">
{String(
dialogs.getGeneratingDialogPlaceholder(
dialogs.activeCanvasGenerationDialog ?? {
mode: 'generate',
prompt: 'fallback',
status: 'idle',
},
)?.x ?? '-',
)}
</span>
<button
type="button"
onClick={() => dialogs.openCanvasGenerationDialog(createDialog('generate', 'first'))}
>
open first
</button>
<button
type="button"
onClick={() => dialogs.openCanvasGenerationDialog(createDialog('character', 'second'))}
>
open second
</button>
<button
type="button"
onClick={() => {
const target = dialogs.inactiveGenerateDialogs[0];
if (target) {
dialogs.activateCanvasGenerationDialog(target);
}
}}
>
activate inactive
</button>
<button
type="button"
onClick={() => {
const target = dialogs.activeCanvasGenerationDialog;
if (target) {
dialogs.updateCanvasGenerationDialogById(target.id, (dialog) => ({
...dialog,
status: 'generating',
placeholder: dialog.placeholder
? {
...dialog.placeholder,
x: 99,
}
: dialog.placeholder,
}));
}
}}
>
update active
</button>
<button
type="button"
onClick={() => {
const target = dialogs.activeCanvasGenerationDialog;
if (target) {
dialogs.removeCanvasGenerationDialogById(target.id);
}
}}
>
remove active
</button>
<button
type="button"
onClick={() => {
const target = dialogs.activeCanvasGenerationDialog;
if (target) {
dialogs.updateCanvasGenerationDialogById(target.id, (dialog) => ({
...dialog,
sourceLayerId: 'layer-a',
}));
}
}}
>
bind layer
</button>
<button
type="button"
onClick={() => dialogs.removeCanvasGenerationDialogsByLayerId('layer-a')}
>
remove layer dialogs
</button>
</div>
);
}
describe('useCanvasGenerationDialogs', () => {
it('archives, activates, updates, and removes canvas generation dialogs', () => {
const onActivate = vi.fn();
render(<GenerationDialogsHarness onActivate={onActivate} />);
act(() => screen.getByRole('button', { name: 'open first' }).click());
expect(screen.getByTestId('active-prompt').textContent).toBe('first');
expect(screen.getByTestId('all-prompts').textContent).toBe('first');
act(() => screen.getByRole('button', { name: 'open second' }).click());
expect(screen.getByTestId('active-prompt').textContent).toBe('second');
expect(screen.getByTestId('inactive').textContent).toContain(
'generation-dialog-1:first:false',
);
expect(screen.getByTestId('all-prompts').textContent).toBe(
'first,second',
);
act(() =>
screen.getByRole('button', { name: 'activate inactive' }).click(),
);
expect(onActivate).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('active-prompt').textContent).toBe('first');
expect(screen.getByTestId('inactive').textContent).toContain(
'generation-dialog-2:second:false',
);
act(() => screen.getByRole('button', { name: 'update active' }).click());
expect(screen.getByTestId('placeholder-x').textContent).toBe('99');
act(() => screen.getByRole('button', { name: 'bind layer' }).click());
act(() =>
screen.getByRole('button', { name: 'remove layer dialogs' }).click(),
);
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
expect(screen.getByTestId('inactive').textContent).toContain('second');
act(() => screen.getByRole('button', { name: 'activate inactive' }).click());
act(() => screen.getByRole('button', { name: 'remove active' }).click());
expect(screen.getByTestId('active-prompt').textContent).toBe('-');
expect(screen.getByTestId('inactive').textContent).toBe('');
});
});

View File

@@ -0,0 +1,188 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { isCanvasGenerationDialog } from './ImageCanvasGenerationModel';
import type {
CanvasGenerationDialogState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
export function useCanvasGenerationDialogs({
onActivate,
}: {
onActivate?: () => void;
} = {}) {
const generationDialogCounterRef = useRef(0);
const generateDialogRef = useRef<GenerateDialogState | null>(null);
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
const [generateDialog, setGenerateDialog] =
useState<GenerateDialogState | null>(null);
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
CanvasGenerationDialogState[]
>([]);
generateDialogRef.current = generateDialog;
inactiveGenerateDialogsRef.current = inactiveGenerateDialogs;
const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog)
? generateDialog
: null;
const canvasGenerationDialogs = useMemo(
() =>
activeCanvasGenerationDialog
? [...inactiveGenerateDialogs, activeCanvasGenerationDialog]
: inactiveGenerateDialogs,
[activeCanvasGenerationDialog, inactiveGenerateDialogs],
);
const createGenerationDialogId = useCallback(() => {
generationDialogCounterRef.current += 1;
return `generation-dialog-${generationDialogCounterRef.current}`;
}, []);
const archiveActiveCanvasGenerationDialog = useCallback(() => {
const currentDialog = generateDialogRef.current;
if (!isCanvasGenerationDialog(currentDialog)) {
return;
}
setInactiveGenerateDialogs((currentDialogs) =>
currentDialogs.some((dialog) => dialog.id === currentDialog.id)
? currentDialogs
: [
...currentDialogs,
{
...currentDialog,
composerOpen: false,
},
],
);
}, []);
const openCanvasGenerationDialog = useCallback(
(dialog: Omit<CanvasGenerationDialogState, 'id'>) => {
archiveActiveCanvasGenerationDialog();
setGenerateDialog({
...dialog,
id: createGenerationDialogId(),
});
},
[archiveActiveCanvasGenerationDialog, createGenerationDialogId],
);
const updateCanvasGenerationDialogById = useCallback(
(dialogId: string, updater: CanvasGenerationDialogUpdater) => {
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 = useCallback(
(dialogId: string) => {
updateCanvasGenerationDialogById(dialogId, () => null);
},
[updateCanvasGenerationDialogById],
);
const activateCanvasGenerationDialog = useCallback(
(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,
});
onActivate?.();
},
[onActivate],
);
const removeCanvasGenerationDialogsByLayerId = useCallback(
(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 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,
archiveActiveCanvasGenerationDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
activateCanvasGenerationDialog,
removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder,
};
}