拆分图片画布生成对象注册表
新增画布生成对象 dialog 管理 hook 补充生成对象注册表 hook 单测 调整 Lovart 式画布背景色板弹层 更新图片画布前端拆分跟踪文档
This commit is contained in:
@@ -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画布工具栏` 保持可见。
|
||||
|
||||
@@ -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 规则进一步稳定后,再从主视图抽出深层状态模型。
|
||||
|
||||
@@ -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: '暖灰' }),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
177
src/components/image-editor/useCanvasGenerationDialogs.test.tsx
Normal file
177
src/components/image-editor/useCanvasGenerationDialogs.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
188
src/components/image-editor/useCanvasGenerationDialogs.ts
Normal file
188
src/components/image-editor/useCanvasGenerationDialogs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
200
src/index.css
200
src/index.css
@@ -4469,14 +4469,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
bottom: calc(100% + 0.55rem);
|
||||
z-index: 24;
|
||||
display: grid;
|
||||
width: min(18.5rem, calc(100vw - 1.5rem));
|
||||
gap: 0.62rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.32);
|
||||
border-radius: 0.82rem;
|
||||
width: min(16rem, calc(100vw - 1.5rem));
|
||||
gap: 0.5rem;
|
||||
border: 1px solid rgba(203, 213, 225, 0.72);
|
||||
border-radius: 0.86rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
padding: 0.72rem;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16);
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-panel-head {
|
||||
@@ -4484,76 +4484,184 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 0.15rem;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 880;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preview {
|
||||
width: 2rem;
|
||||
height: 1.35rem;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #cbd5e1;
|
||||
.image-canvas-editor__background-close {
|
||||
display: inline-flex;
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.42rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.5);
|
||||
background: transparent;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-panel button {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-close:hover {
|
||||
border-color: #d7dfe9;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 8.2rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
background:
|
||||
linear-gradient(to top, #000000, transparent),
|
||||
linear-gradient(to right, #ffffff, transparent),
|
||||
#ef4444;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum input,
|
||||
.image-canvas-editor__background-hue input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(to top, #000000, transparent),
|
||||
linear-gradient(to right, #ffffff, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum-handle {
|
||||
position: absolute;
|
||||
top: 0.22rem;
|
||||
left: 0.22rem;
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.25);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-hue {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 0.78rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#ff0000,
|
||||
#ffff00,
|
||||
#00ff00,
|
||||
#00ffff,
|
||||
#0000ff,
|
||||
#ff00ff,
|
||||
#ff0000
|
||||
);
|
||||
box-shadow: 0 0 0 1px rgba(203, 213, 225, 0.72);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.42rem;
|
||||
display: flex;
|
||||
gap: 0.62rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.08rem 0.05rem 0.18rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-presets::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preset {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
justify-items: center;
|
||||
gap: 0.26rem;
|
||||
border: 1px solid #d7dfe9;
|
||||
border-radius: 0.58rem;
|
||||
background: #ffffff;
|
||||
padding: 0.44rem 0.28rem;
|
||||
color: #475569;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 820;
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-preset {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preset[aria-pressed='true'] {
|
||||
border-color: #38bdf8;
|
||||
background: #e0f2fe;
|
||||
color: #0369a1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-field {
|
||||
display: grid;
|
||||
grid-template-columns: 4.1rem minmax(0, 1fr);
|
||||
.image-canvas-editor__background-preset .image-canvas-editor__background-swatch {
|
||||
width: 1.55rem;
|
||||
height: 1.55rem;
|
||||
border-color: #e2e8f0;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-hex-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
gap: 0.45rem;
|
||||
min-height: 1.9rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f4f5f7;
|
||||
padding: 0 0.62rem;
|
||||
color: #64748b;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 840;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-field input[type='color'] {
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
border: 1px solid #d7dfe9;
|
||||
border-radius: 0.56rem;
|
||||
background: #ffffff;
|
||||
padding: 0.16rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-field--hex .platform-text-field {
|
||||
.image-canvas-editor__background-hex-field input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #475569;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
'Courier New', monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 820;
|
||||
outline: none;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-reset {
|
||||
justify-self: end;
|
||||
display: inline-flex;
|
||||
justify-self: start;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
border-radius: 0.56rem;
|
||||
padding-inline: 0.65rem;
|
||||
border: 1px solid #d7dfe9;
|
||||
border-radius: 0.5rem;
|
||||
background: #ffffff;
|
||||
padding: 0.34rem 0.55rem;
|
||||
color: #475569;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-reset:hover {
|
||||
border-color: #38bdf8;
|
||||
background: #eff6ff;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock button:hover,
|
||||
|
||||
Reference in New Issue
Block a user