拆分图片画布顶部栏视图

新增 ImageCanvasTopbarView 承载返回项目入口和项目标题区域

新增顶栏视图测试覆盖重命名和导出入口

精简 ImageCanvasEditorView 的顶部栏 JSX

更新图片画布拆分计划和 TRACKING 验证记录
This commit is contained in:
2026-06-17 13:48:48 +08:00
parent 1c92db19c1
commit a319d2ae0d
6 changed files with 325 additions and 108 deletions

View File

@@ -1,14 +1,11 @@
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useAuthUi } from '../auth/AuthUiContext';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView';
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import {
isCanvasGenerationComposerVisible,
@@ -731,108 +728,23 @@ export function ImageCanvasEditorView() {
/>
<div className="image-canvas-editor__main">
<div className="image-canvas-editor__topbar">
<a
className="image-canvas-editor__project-back-button"
href="/project"
aria-label="返回项目页面"
title="返回项目"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
<div className="image-canvas-editor__title-block">
{isRenamingProject ? (
<form
className="image-canvas-editor__project-title-form"
onSubmit={(event) => {
event.preventDefault();
submitProjectRename(projectId);
}}
>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
resetProjectRenameError();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelProjectRename();
}
}}
/>
<EditorIconButton
type="submit"
label="保存项目名称"
title="保存"
icon={Check}
disabled={isProjectRenameSaving}
/>
<EditorIconButton
label="取消修改项目名称"
title="取消"
icon={X}
disabled={isProjectRenameSaving}
onClick={cancelProjectRename}
/>
{projectRenameError ? (
<span
className="image-canvas-editor__project-title-error"
role="alert"
>
{projectRenameError}
</span>
) : null}
</form>
) : (
<div className="image-canvas-editor__project-title-row">
<button
type="button"
className="image-canvas-editor__project-title-button"
onDoubleClick={startProjectRename}
aria-label={`编辑项目名称${projectTitle}`}
>
<h1>{projectTitle}</h1>
</button>
<EditorIconButton
className="image-canvas-editor__project-rename-button"
label="编辑项目名称"
title="编辑项目名称"
icon={Pencil}
onClick={startProjectRename}
/>
</div>
)}
<span></span>
</div>
<div className="image-canvas-editor__topbar-actions">
<EditorIconButton
label="下载画布素材"
title="下载画布素材"
icon={Download}
disabled={
isExportingAssets ||
!layers.some((layer) => layer.src.trim().length > 0)
}
onClick={() => void exportCanvasAssets()}
/>
{assetExportStatus ? (
<PlatformStatusMessage
tone={assetExportStatus.tone}
surface="platform"
size="xs"
role={assetExportStatus.tone === 'error' ? 'alert' : 'status'}
>
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
</div>
</div>
<ImageCanvasTopbarView
projectId={projectId}
projectTitle={projectTitle}
projectRenameValue={projectRenameValue}
isRenamingProject={isRenamingProject}
isProjectRenameSaving={isProjectRenameSaving}
projectRenameError={projectRenameError}
layers={layers}
assetExportStatus={assetExportStatus}
isExportingAssets={isExportingAssets}
setProjectRenameValue={setProjectRenameValue}
startProjectRename={startProjectRename}
cancelProjectRename={cancelProjectRename}
submitProjectRename={submitProjectRename}
resetProjectRenameError={resetProjectRenameError}
exportCanvasAssets={exportCanvasAssets}
/>
<ImageCanvasStageView
canvasViewportRef={canvasViewportRef}

View File

@@ -0,0 +1,147 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasTopbarView } from './ImageCanvasTopbarView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
const id = overrides.id ?? 'layer-a';
return {
id,
resourceId: `resource-${id}`,
title: id,
src: `data:image/png;base64,${id}`,
x: 0,
y: 0,
width: 320,
height: 180,
originalWidth: 320,
originalHeight: 180,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function renderTopbar(
overrides: Partial<
Parameters<typeof ImageCanvasTopbarView>[0]
> = {},
) {
const props: Parameters<typeof ImageCanvasTopbarView>[0] = {
projectId: 'project-a',
projectTitle: '默认项目',
projectRenameValue: '默认项目',
isRenamingProject: false,
isProjectRenameSaving: false,
projectRenameError: null,
layers: [],
assetExportStatus: null,
isExportingAssets: false,
setProjectRenameValue: vi.fn(),
startProjectRename: vi.fn(),
cancelProjectRename: vi.fn(),
submitProjectRename: vi.fn(),
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
...overrides,
};
render(<ImageCanvasTopbarView {...props} />);
return props;
}
describe('ImageCanvasTopbarView', () => {
it('shows the project title and project gallery link', () => {
const props = renderTopbar();
expect(screen.getByRole('heading', { name: '默认项目' })).toBeTruthy();
expect(
screen.getByRole('link', { name: '返回项目页面' }).getAttribute('href'),
).toBe('/project');
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
expect(props.startProjectRename).toHaveBeenCalledTimes(1);
});
it('submits, resets, and cancels project rename edits', () => {
const props = renderTopbar({
isRenamingProject: true,
projectRenameValue: '草稿项目',
projectRenameError: '项目名称不能为空',
});
const input = screen.getByLabelText('项目名称');
fireEvent.change(input, { target: { value: '新项目' } });
fireEvent.click(screen.getByRole('button', { name: '保存项目名称' }));
expect(props.setProjectRenameValue).toHaveBeenCalledWith('新项目');
expect(props.resetProjectRenameError).toHaveBeenCalledTimes(1);
expect(props.submitProjectRename).toHaveBeenCalledWith('project-a');
expect(screen.getByRole('alert').textContent).toBe('项目名称不能为空');
fireEvent.keyDown(input, { key: 'Escape' });
expect(props.cancelProjectRename).toHaveBeenCalledTimes(1);
});
it('exports only when canvas has exportable layers and shows export status', () => {
const exportCanvasAssets = vi.fn();
const { rerender } = render(
<ImageCanvasTopbarView
projectId="project-a"
projectTitle="默认项目"
projectRenameValue="默认项目"
isRenamingProject={false}
isProjectRenameSaving={false}
projectRenameError={null}
layers={[]}
assetExportStatus={null}
isExportingAssets={false}
setProjectRenameValue={vi.fn()}
startProjectRename={vi.fn()}
cancelProjectRename={vi.fn()}
submitProjectRename={vi.fn()}
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
/>,
);
expect(
(screen.getByRole('button', { name: '下载画布素材' }) as HTMLButtonElement)
.disabled,
).toBe(true);
rerender(
<ImageCanvasTopbarView
projectId="project-a"
projectTitle="默认项目"
projectRenameValue="默认项目"
isRenamingProject={false}
isProjectRenameSaving={false}
projectRenameError={null}
layers={[createLayer()]}
assetExportStatus={{
tone: 'success',
message: '画布素材已导出',
}}
isExportingAssets={false}
setProjectRenameValue={vi.fn()}
startProjectRename={vi.fn()}
cancelProjectRename={vi.fn()}
submitProjectRename={vi.fn()}
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '下载画布素材' }));
expect(exportCanvasAssets).toHaveBeenCalledTimes(1);
expect(screen.getByRole('status').textContent).toBe('画布素材已导出');
});
});

View File

@@ -0,0 +1,149 @@
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import type { AssetExportStatus } from './useImageCanvasAssetExportWorkflow';
type ImageCanvasTopbarViewProps = {
projectId: string | null;
projectTitle: string;
projectRenameValue: string;
isRenamingProject: boolean;
isProjectRenameSaving: boolean;
projectRenameError: string | null;
layers: CanvasLayer[];
assetExportStatus: AssetExportStatus | null;
isExportingAssets: boolean;
setProjectRenameValue: (value: string) => void;
startProjectRename: () => void;
cancelProjectRename: () => void;
submitProjectRename: (projectId: string | null) => void;
resetProjectRenameError: () => void;
exportCanvasAssets: () => void | Promise<void>;
};
export function ImageCanvasTopbarView({
projectId,
projectTitle,
projectRenameValue,
isRenamingProject,
isProjectRenameSaving,
projectRenameError,
layers,
assetExportStatus,
isExportingAssets,
setProjectRenameValue,
startProjectRename,
cancelProjectRename,
submitProjectRename,
resetProjectRenameError,
exportCanvasAssets,
}: ImageCanvasTopbarViewProps) {
const hasExportableLayer = layers.some(
(layer) => layer.src.trim().length > 0,
);
return (
<div className="image-canvas-editor__topbar">
<a
className="image-canvas-editor__project-back-button"
href="/project"
aria-label="返回项目页面"
title="返回项目"
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</a>
<div className="image-canvas-editor__title-block">
{isRenamingProject ? (
<form
className="image-canvas-editor__project-title-form"
onSubmit={(event) => {
event.preventDefault();
submitProjectRename(projectId);
}}
>
<PlatformTextField
aria-label="项目名称"
value={projectRenameValue}
autoFocus
disabled={isProjectRenameSaving}
className="image-canvas-editor__project-title-input"
onChange={(event) => {
setProjectRenameValue(event.target.value);
resetProjectRenameError();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelProjectRename();
}
}}
/>
<EditorIconButton
type="submit"
label="保存项目名称"
title="保存"
icon={Check}
disabled={isProjectRenameSaving}
/>
<EditorIconButton
label="取消修改项目名称"
title="取消"
icon={X}
disabled={isProjectRenameSaving}
onClick={cancelProjectRename}
/>
{projectRenameError ? (
<span
className="image-canvas-editor__project-title-error"
role="alert"
>
{projectRenameError}
</span>
) : null}
</form>
) : (
<div className="image-canvas-editor__project-title-row">
<button
type="button"
className="image-canvas-editor__project-title-button"
onDoubleClick={startProjectRename}
aria-label={`编辑项目名称${projectTitle}`}
>
<h1>{projectTitle}</h1>
</button>
<EditorIconButton
className="image-canvas-editor__project-rename-button"
label="编辑项目名称"
title="编辑项目名称"
icon={Pencil}
onClick={startProjectRename}
/>
</div>
)}
<span></span>
</div>
<div className="image-canvas-editor__topbar-actions">
<EditorIconButton
label="下载画布素材"
title="下载画布素材"
icon={Download}
disabled={isExportingAssets || !hasExportableLayer}
onClick={() => void exportCanvasAssets()}
/>
{assetExportStatus ? (
<PlatformStatusMessage
tone={assetExportStatus.tone}
surface="platform"
size="xs"
role={assetExportStatus.tone === 'error' ? 'alert' : 'status'}
>
{assetExportStatus.message}
</PlatformStatusMessage>
) : null}
</div>
</div>
);
}

View File

@@ -16,7 +16,7 @@ import {
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
type AssetExportStatus = {
export type AssetExportStatus = {
tone: 'info' | 'success' | 'error';
message: string;
};