diff --git a/TRACKING.md b/TRACKING.md
index 6b0e9257..09adb9bd 100644
--- a/TRACKING.md
+++ b/TRACKING.md
@@ -17,7 +17,7 @@
| 前端交互 | 已完成 | 已实现缩放菜单、工具模式、Space 抓手、中键平移、吸附线、元数据弹窗和右侧真实修改结果。 |
| 素材库增强 | 已完成 | 已实现账号级素材库持久化、文件夹新建 / 折叠 / 重命名 / 删除、多文件上传、拖拽定向上传、素材框选与批量删除。 |
| 画布增强 | 已完成 | 已实现拖拽上传到画布并创建图层、图层打组、Ctrl/Cmd 滚轮缩放、普通滚轮纵向滚动和小地图拖拽移动视图。 |
-| 前端拆分 | 进行中 | 已新增前端拆分计划,抽出类型、画布模型、生成模型和导出模型,主视图保留状态编排与 JSX 工作面。 |
+| 前端拆分 | 进行中 | 已新增前端拆分计划,抽出类型、画布模型、生成模型、导出模型和素材 / 图层整合侧栏视图,主视图保留状态编排与跨画布状态机。 |
| 验证 | 已完成 | 聚焦测试、类型检查、Rust 检查、schema guard、编码检查、diff 空白检查和浏览器 smoke 已通过。 |
## 待办清单
@@ -112,3 +112,5 @@
- 2026-06-16 编辑器回归修正:工程 / 素材 / 上传等编辑器请求恢复全局 401 / 403 登录弹窗;未登录上传会先弹登录并在登录后续传;画布背景入口恢复为 `画布背景设置` 面板,支持预设色、自定义颜色、HEX 输入、非法值不应用、恢复默认和 Escape 关闭。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器 smoke:`http://127.0.0.1:10006/editor/canvas` 未登录打开 `账号入口`,登录后上传素材成功,背景面板打开后点击“暖灰”使画布背景变为 `rgb(243, 240, 234)`。
- 2026-06-17 前端拆分第一阶段:新增 `ImageCanvasEditorTypes`、`ImageCanvasEditorModel`、`ImageCanvasGenerationModel` 和 `ImageCanvasExportModel`,把类型、画布快照 / 吸附 / 背景、生成输入快照和导出元数据规则从 `ImageCanvasEditorView` 抽出;新增模型层单测,主视图从 8286 行降至 7054 行。
- 2026-06-17 浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录打开工程和未登录上传均弹出 `账号入口`;关闭登录后点击 `画布背景色` 打开 `画布背景设置` 面板,点击 `暖灰` 后画布背景为 `rgb(243, 240, 234)`;登录开发账号后上传图片成功进入 `项目素材`,`AI画布工具栏` 保持可见。
+- 2026-06-17 前端拆分第二阶段:新增 `ImageCanvasSidebarView`,把素材 / 图层共用左侧整合面板从主视图抽出;上传链路、登录弹窗、素材拖到画布、持久化、图层历史和右键菜单状态机仍保留在主视图,避免过度拆分。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`、`npm run typecheck`。
+- 2026-06-17 侧栏拆分浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`;`画布背景色` 打开 `画布背景设置` dialog,包含预设、自定义颜色、HEX 和恢复默认;使用临时开发账号登录后上传图片成功进入 `项目素材`,点击素材可添加到画布,切换 `图层` 侧栏后能看到同一图片图层,`AI画布工具栏` 保持可见。
diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
index 7e4c270a..9acf73d7 100644
--- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
+++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
@@ -34,9 +34,17 @@
- 承载画布素材导出的底层规则:文件名清理、日期格式、图片去重 key、Data URL 转 Blob、Blob 读取和图层导出元数据。
- ZIP 组包和下载触发仍留在主视图,作为 UI 状态编排的一部分。
+## 第二阶段模块
+
+- `ImageCanvasSidebarView.tsx`
+ - 承载素材 / 图层共用左侧整合面板的 JSX,包括素材文件夹、新建 / 折叠 / 重命名 / 删除、上传入口、素材选择模式、框选层、批量删除、素材拖到文件夹和图层列表。
+ - 继续通过 props 调用主视图状态机,不接管上传、登录弹窗、持久化、拖到画布的坐标换算和图层历史记录。
+ - 保持“素材”和“图层”同一侧栏切换的 Lovart 式布局,不恢复右侧独立图层栏或左侧竖向工具栏。
+
+第二阶段以后,主视图仍是画布编排入口。继续拆分前应优先选择能形成稳定边界的深模块,避免把上传链路、DataTransfer、画布坐标和历史快照拆成互相回调的小碎片。
+
## 后续阶段
-- `ImageCanvasSidebarView`:素材 / 图层共用侧栏,等模型层稳定后再拆。
- `ImageCanvasStageView`:画布 viewport、图层渲染、右键菜单和生成占位框,等交互回归覆盖更强后再拆。
- `ImageCanvasGenerationDock`:底部 AI 工具栏和生成面板族,等生成对象状态机进一步收口后再拆。
diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx
index a4a5bd96..75f6d1ad 100644
--- a/src/components/image-editor/ImageCanvasEditorView.tsx
+++ b/src/components/image-editor/ImageCanvasEditorView.tsx
@@ -1,7 +1,6 @@
import {
Braces,
Check,
- CheckSquare,
ChevronDown,
ChevronLeft,
ChevronRight,
@@ -10,7 +9,6 @@ import {
Crop,
Download,
Folder,
- FolderPlus,
Hand,
ImageIcon,
ImagePlus,
@@ -19,13 +17,11 @@ import {
Map as MapIcon,
MousePointer2,
Pencil,
- PencilLine,
Redo2,
RotateCcw,
Shapes,
SlidersHorizontal,
Sparkles,
- Square,
Trash2,
Type,
Undo2,
@@ -71,7 +67,6 @@ import {
updateEditorAssetFolder,
} from '../../services/image-editor/editorProjectClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
-import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
import {
PlatformFloatingMenu,
@@ -87,10 +82,8 @@ import {
} from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { useAuthUi } from '../auth/AuthUiContext';
-import {
- EditorIconButton,
- SidebarMediaItem,
-} from './ImageCanvasEditorPrimitives';
+import { EditorIconButton } from './ImageCanvasEditorPrimitives';
+import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import {
ASSET_DRAG_MIME_TYPE,
CANVAS_BACKGROUND_OPTIONS,
@@ -4172,662 +4165,64 @@ export function ImageCanvasEditorView() {
?.label ?? '素材'}
) : null}
- {activeSidebarPanel ? (
-
- ) : null}
+
diff --git a/src/components/image-editor/ImageCanvasSidebarView.tsx b/src/components/image-editor/ImageCanvasSidebarView.tsx
new file mode 100644
index 00000000..a17a55ea
--- /dev/null
+++ b/src/components/image-editor/ImageCanvasSidebarView.tsx
@@ -0,0 +1,828 @@
+import {
+ Check,
+ CheckSquare,
+ ChevronDown,
+ ChevronRight,
+ Folder,
+ FolderPlus,
+ ImagePlus,
+ Pencil,
+ PencilLine,
+ Square,
+ Trash2,
+ X,
+} from 'lucide-react';
+import type {
+ Dispatch,
+ PointerEvent as ReactPointerEvent,
+ RefObject,
+ SetStateAction,
+} from 'react';
+
+import { PlatformActionButton } from '../common/PlatformActionButton';
+import { PlatformBatchActionToolbar } from '../common/PlatformBatchActionToolbar';
+import { PlatformTextField } from '../common/PlatformTextField';
+import {
+ EditorIconButton,
+ SidebarMediaItem,
+} from './ImageCanvasEditorPrimitives';
+import {
+ ASSET_DRAG_MIME_TYPE,
+ clamp,
+ getDraggedAssetId,
+ hasDataTransferType,
+} from './ImageCanvasEditorModel';
+import type {
+ AssetMarqueeState,
+ AssetPointerDragState,
+ CanvasContextMenuState,
+ CanvasLayer,
+ EditorAsset,
+ EditorAssetFolder,
+ ImageContextMenuState,
+ SidebarPanel,
+ UploadTarget,
+} from './ImageCanvasEditorTypes';
+
+export type GroupedEditorAssetFolder = EditorAssetFolder & {
+ assets: EditorAsset[];
+};
+
+type UploadFilesOptions = {
+ folderId?: string;
+ canvasPoint?: { x: number; y: number };
+ addToCanvas?: boolean;
+};
+
+type ImageCanvasSidebarViewProps = {
+ activeSidebarPanel: SidebarPanel | null;
+ assetListRef: RefObject
;
+ uploadInputRef: RefObject;
+ assetPointerDragRef: { current: AssetPointerDragState | null };
+ suppressAssetClickRef: { current: boolean };
+ assets: EditorAsset[];
+ groupedAssets: GroupedEditorAssetFolder[];
+ assetFolders: EditorAssetFolder[];
+ layers: CanvasLayer[];
+ selectedLayerId: string | null;
+ selectedLayerIds: string[];
+ isAssetSelectionMode: boolean;
+ selectedAssetIds: Set;
+ assetMoveDropFolderId: string | null;
+ pinnedAssetMoveFolderId: string | null;
+ creatingFolder: boolean;
+ newFolderName: string;
+ renamingFolder: { folderId: string; value: string } | null;
+ renamingAsset: { assetId: string; value: string } | null;
+ allSelectableAssetsSelected: boolean;
+ assetMarquee: AssetMarqueeState | null;
+ setIsAssetSelectionMode: Dispatch>;
+ setCreatingFolder: Dispatch>;
+ setNewFolderName: Dispatch>;
+ setRenamingFolder: Dispatch<
+ SetStateAction<{ folderId: string; value: string } | null>
+ >;
+ setRenamingAsset: Dispatch<
+ SetStateAction<{ assetId: string; value: string } | null>
+ >;
+ setActiveUploadFolderId: Dispatch>;
+ setUploadTarget: Dispatch>;
+ setUploadDropTarget: Dispatch>;
+ setAssetPointerDrag: Dispatch>;
+ setSelectedAssetIds: Dispatch>>;
+ setImageContextMenu: Dispatch>;
+ setContextMenu: Dispatch>;
+ onAssetMarqueePointerDown: (
+ event: ReactPointerEvent,
+ ) => void;
+ onAssetMarqueePointerMove: (
+ event: ReactPointerEvent,
+ ) => void;
+ onAssetMarqueePointerUp: (
+ event: ReactPointerEvent,
+ ) => void;
+ updateAssetMoveDropFolder: (folderId: string | null) => void;
+ addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
+ moveAssetToFolder: (assetId: string, folderId: string) => void;
+ commitNewAssetFolder: () => void | Promise;
+ toggleAssetFolder: (folderId: string) => void;
+ startRenamingFolder: (folder: EditorAssetFolder) => void;
+ commitFolderRename: (folder: EditorAssetFolder) => void;
+ deleteAssetFolder: (folder: EditorAssetFolder) => void;
+ startRenamingAsset: (asset: EditorAsset) => void;
+ commitAssetRename: (asset: EditorAsset) => void;
+ deleteUploadedAsset: (asset: EditorAsset) => void;
+ toggleAssetSelected: (assetId: string) => void;
+ addAssetLayer: (asset: EditorAsset) => void;
+ toggleAllAssetsSelected: () => void;
+ deleteSelectedAssets: () => void;
+ closeAssetSelectionMode: () => void;
+ groupSelectedLayers: () => void;
+ selectSingleLayer: (layerId: string | null) => void;
+ resolveContextMenuPosition: (
+ clientX: number,
+ clientY: number,
+ menuKind: 'blank' | 'layer',
+ ) => Omit;
+ getCanvasPointFromClient: (
+ clientX: number,
+ clientY: number,
+ ) => { x: number; y: number };
+};
+
+export function ImageCanvasSidebarView({
+ activeSidebarPanel,
+ assetListRef,
+ uploadInputRef,
+ assetPointerDragRef,
+ suppressAssetClickRef,
+ assets,
+ groupedAssets,
+ assetFolders,
+ layers,
+ selectedLayerId,
+ selectedLayerIds,
+ isAssetSelectionMode,
+ selectedAssetIds,
+ assetMoveDropFolderId,
+ pinnedAssetMoveFolderId,
+ creatingFolder,
+ newFolderName,
+ renamingFolder,
+ renamingAsset,
+ allSelectableAssetsSelected,
+ assetMarquee,
+ setIsAssetSelectionMode,
+ setCreatingFolder,
+ setNewFolderName,
+ setRenamingFolder,
+ setRenamingAsset,
+ setActiveUploadFolderId,
+ setUploadTarget,
+ setUploadDropTarget,
+ setAssetPointerDrag,
+ setSelectedAssetIds,
+ setImageContextMenu,
+ setContextMenu,
+ onAssetMarqueePointerDown,
+ onAssetMarqueePointerMove,
+ onAssetMarqueePointerUp,
+ updateAssetMoveDropFolder,
+ addUploadedFiles,
+ moveAssetToFolder,
+ commitNewAssetFolder,
+ toggleAssetFolder,
+ startRenamingFolder,
+ commitFolderRename,
+ deleteAssetFolder,
+ startRenamingAsset,
+ commitAssetRename,
+ deleteUploadedAsset,
+ toggleAssetSelected,
+ addAssetLayer,
+ toggleAllAssetsSelected,
+ deleteSelectedAssets,
+ closeAssetSelectionMode,
+ groupSelectedLayers,
+ selectSingleLayer,
+ resolveContextMenuPosition,
+ getCanvasPointFromClient,
+}: ImageCanvasSidebarViewProps) {
+ if (!activeSidebarPanel) {
+ return null;
+ }
+
+ return (
+
+ );
+}