拆分图片画布编辑器侧栏视图
抽出素材和图层左侧整合面板为 ImageCanvasSidebarView 保留上传、登录、拖到画布和持久化状态机在主视图 更新前端拆分计划和 TRACKING 验证记录
This commit is contained in:
@@ -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画布工具栏` 保持可见。
|
||||
|
||||
@@ -34,9 +34,17 @@
|
||||
- 承载画布素材导出的底层规则:文件名清理、日期格式、图片去重 key、Data URL 转 Blob、Blob 读取和图层导出元数据。
|
||||
- ZIP 组包和下载触发仍留在主视图,作为 UI 状态编排的一部分。
|
||||
|
||||
## 第二阶段模块
|
||||
|
||||
- `ImageCanvasSidebarView.tsx`
|
||||
- 承载素材 / 图层共用左侧整合面板的 JSX,包括素材文件夹、新建 / 折叠 / 重命名 / 删除、上传入口、素材选择模式、框选层、批量删除、素材拖到文件夹和图层列表。
|
||||
- 继续通过 props 调用主视图状态机,不接管上传、登录弹窗、持久化、拖到画布的坐标换算和图层历史记录。
|
||||
- 保持“素材”和“图层”同一侧栏切换的 Lovart 式布局,不恢复右侧独立图层栏或左侧竖向工具栏。
|
||||
|
||||
第二阶段以后,主视图仍是画布编排入口。继续拆分前应优先选择能形成稳定边界的深模块,避免把上传链路、DataTransfer、画布坐标和历史快照拆成互相回调的小碎片。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- `ImageCanvasSidebarView`:素材 / 图层共用侧栏,等模型层稳定后再拆。
|
||||
- `ImageCanvasStageView`:画布 viewport、图层渲染、右键菜单和生成占位框,等交互回归覆盖更强后再拆。
|
||||
- `ImageCanvasGenerationDock`:底部 AI 工具栏和生成面板族,等生成对象状态机进一步收口后再拆。
|
||||
|
||||
|
||||
@@ -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 ?? '素材'}
|
||||
</div>
|
||||
) : null}
|
||||
{activeSidebarPanel ? (
|
||||
<aside className="image-canvas-editor__sidebar" aria-label="图片资源栏">
|
||||
<div className="image-canvas-editor__sidebar-header">
|
||||
<div className="min-w-0">
|
||||
<h2 className="image-canvas-editor__sidebar-title">
|
||||
{activeSidebarPanel === 'assets' ? '素材' : '图层'}
|
||||
</h2>
|
||||
<div className="image-canvas-editor__sidebar-count">
|
||||
{activeSidebarPanel === 'assets'
|
||||
? assets.length
|
||||
: layers.length}
|
||||
</div>
|
||||
</div>
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
<div className="image-canvas-editor__sidebar-header-actions">
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="素材选择模式"
|
||||
title="选择"
|
||||
icon={isAssetSelectionMode ? CheckSquare : Square}
|
||||
pressed={isAssetSelectionMode}
|
||||
onClick={() =>
|
||||
setIsAssetSelectionMode((currentMode) => !currentMode)
|
||||
}
|
||||
/>
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="新建素材文件夹"
|
||||
title="新建文件夹"
|
||||
icon={FolderPlus}
|
||||
onClick={() => setCreatingFolder(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="图层打组"
|
||||
title="打组"
|
||||
icon={FolderPlus}
|
||||
disabled={!selectedLayerId && selectedLayerIds.length === 0}
|
||||
onClick={groupSelectedLayers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
<div
|
||||
ref={assetListRef}
|
||||
className="image-canvas-editor__asset-list"
|
||||
onPointerDown={handleAssetMarqueePointerDown}
|
||||
onPointerMove={handleAssetMarqueePointerMove}
|
||||
onPointerUp={handleAssetMarqueePointerUp}
|
||||
onPointerCancel={handleAssetMarqueePointerUp}
|
||||
>
|
||||
{pinnedAssetMoveFolderId ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-sticky-target"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{assetFolders.find(
|
||||
(folder) => folder.id === pinnedAssetMoveFolderId,
|
||||
)?.label ?? '目标文件夹'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{creatingFolder ? (
|
||||
<form
|
||||
className="image-canvas-editor__folder-create"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitNewAssetFolder();
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
aria-label="素材文件夹名称"
|
||||
value={newFolderName}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-create-input"
|
||||
onChange={(event) => setNewFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EditorIconButton
|
||||
type="submit"
|
||||
label="保存素材文件夹"
|
||||
icon={Check}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="取消新建素材文件夹"
|
||||
icon={X}
|
||||
onClick={() => {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
{groupedAssets.map((folder) => (
|
||||
<section
|
||||
key={folder.id}
|
||||
className={[
|
||||
'image-canvas-editor__asset-folder',
|
||||
assetMoveDropFolderId === folder.id
|
||||
? 'image-canvas-editor__asset-folder--move-target'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={folder.label}
|
||||
data-asset-folder-id={folder.id}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
hasDataTransferType(
|
||||
event.dataTransfer,
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(folder.id);
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, 'Files')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget('assets');
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const movingAssetId = getDraggedAssetId(event.dataTransfer);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
moveAssetToFolder(movingAssetId, folder.id);
|
||||
return;
|
||||
}
|
||||
if (!event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
addUploadedFiles(event.dataTransfer.files, {
|
||||
folderId: folder.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-header"
|
||||
data-asset-folder-header-id={folder.id}
|
||||
>
|
||||
<EditorIconButton
|
||||
label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
|
||||
title={folder.collapsed ? '展开' : '折叠'}
|
||||
icon={folder.collapsed ? ChevronRight : ChevronDown}
|
||||
expanded={!folder.collapsed}
|
||||
onClick={() => toggleAssetFolder(folder.id)}
|
||||
/>
|
||||
<Folder className="h-4 w-4" />
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名文件夹${folder.label}`}
|
||||
value={renamingFolder.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-rename-input"
|
||||
onChange={(event) =>
|
||||
setRenamingFolder({
|
||||
folderId: folder.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitFolderRename(folder);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setRenamingFolder(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{folder.label}</span>
|
||||
)}
|
||||
<span>{folder.assets.length}</span>
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<>
|
||||
<EditorIconButton
|
||||
label={`保存文件夹${folder.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitFolderRename(folder)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名文件夹${folder.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingFolder(null)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
label={`重命名文件夹${folder.label}`}
|
||||
title="重命名"
|
||||
icon={PencilLine}
|
||||
onClick={() => startRenamingFolder(folder)}
|
||||
/>
|
||||
)}
|
||||
{!folder.systemDefault ? (
|
||||
<EditorIconButton
|
||||
label={`删除文件夹${folder.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteAssetFolder(folder)}
|
||||
/>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label={`上传到${folder.label}`}
|
||||
title="上传"
|
||||
icon={ImagePlus}
|
||||
onClick={() => {
|
||||
setActiveUploadFolderId(folder.id);
|
||||
setUploadTarget('asset');
|
||||
uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-list"
|
||||
hidden={folder.collapsed}
|
||||
>
|
||||
{folder.assets.map((asset) => {
|
||||
const isRenaming = renamingAsset?.assetId === asset.id;
|
||||
const isUploadingAsset =
|
||||
asset.uploadStatus === 'uploading';
|
||||
const isFailedUpload = asset.uploadStatus === 'failed';
|
||||
const uploadProgress = clamp(
|
||||
asset.uploadProgress ?? 0,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
const titleNode = isRenaming ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名素材${asset.label}`}
|
||||
value={renamingAsset.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__asset-rename-input"
|
||||
onChange={(event) =>
|
||||
setRenamingAsset({
|
||||
assetId: asset.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitAssetRename(asset);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setRenamingAsset(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
const actions = isUploadingAsset ? (
|
||||
<div className="image-canvas-editor__asset-upload-status">
|
||||
<span>{asset.uploadMessage ?? '上传中'}</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
) : isRenaming ? (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`保存素材${asset.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitAssetRename(asset)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名素材${asset.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingAsset(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`重命名素材${asset.label}`}
|
||||
title="重命名"
|
||||
icon={Pencil}
|
||||
onClick={() => startRenamingAsset(asset)}
|
||||
/>
|
||||
{asset.sourceKind === 'uploaded' ? (
|
||||
<EditorIconButton
|
||||
label={`删除素材${asset.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteUploadedAsset(asset)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={asset.id} data-asset-id={asset.id}>
|
||||
<SidebarMediaItem
|
||||
title={asset.label}
|
||||
detail={`${asset.width} x ${asset.height}`}
|
||||
imageSrc={asset.src}
|
||||
imageAlt={`素材:${asset.label}`}
|
||||
primaryLabel={
|
||||
isUploadingAsset
|
||||
? `上传中${asset.label}`
|
||||
: isFailedUpload
|
||||
? `上传失败${asset.label}`
|
||||
: isAssetSelectionMode
|
||||
? `选择素材${asset.label}`
|
||||
: `添加${asset.label}`
|
||||
}
|
||||
onPrimaryClick={() => {
|
||||
if (isUploadingAsset || isFailedUpload) {
|
||||
return;
|
||||
}
|
||||
if (suppressAssetClickRef.current) {
|
||||
return;
|
||||
}
|
||||
if (isAssetSelectionMode) {
|
||||
toggleAssetSelected(asset.id);
|
||||
return;
|
||||
}
|
||||
addAssetLayer(asset);
|
||||
}}
|
||||
selected={selectedAssetIds.has(asset.id)}
|
||||
rowClassName={[
|
||||
'image-canvas-editor__asset-row',
|
||||
isUploadingAsset
|
||||
? 'image-canvas-editor__asset-row--uploading'
|
||||
: '',
|
||||
isFailedUpload
|
||||
? 'image-canvas-editor__asset-row--upload-failed'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
primaryClassName="image-canvas-editor__asset-button"
|
||||
thumbnailClassName="image-canvas-editor__asset-thumb"
|
||||
metaClassName="image-canvas-editor__asset-meta"
|
||||
titleNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
<span>{asset.label}</span>
|
||||
) : (
|
||||
titleNode
|
||||
)
|
||||
}
|
||||
actions={actions}
|
||||
draggable={
|
||||
!isRenaming && !isUploadingAsset && !isFailedUpload
|
||||
}
|
||||
previewOverlay={
|
||||
isUploadingAsset ? (
|
||||
<div className="image-canvas-editor__asset-upload-overlay">
|
||||
<span>上传中</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
footerNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
<div className="image-canvas-editor__asset-upload-progress">
|
||||
<div>
|
||||
<span>
|
||||
{isFailedUpload
|
||||
? (asset.uploadMessage ?? '上传失败')
|
||||
: (asset.uploadMessage ?? '上传中')}
|
||||
</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={`素材${asset.label}上传进度`}
|
||||
max={100}
|
||||
value={uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (
|
||||
isRenaming ||
|
||||
isUploadingAsset ||
|
||||
isFailedUpload
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
assetPointerDragRef.current?.assetId ===
|
||||
asset.id
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
asset.id,
|
||||
);
|
||||
event.dataTransfer.setData(
|
||||
'text/plain',
|
||||
asset.label,
|
||||
);
|
||||
event.dataTransfer.setData(
|
||||
'text/uri-list',
|
||||
asset.src,
|
||||
);
|
||||
updateAssetMoveDropFolder(asset.folderId);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
if (isAssetSelectionMode) {
|
||||
if (target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isRenaming ||
|
||||
isUploadingAsset ||
|
||||
isFailedUpload ||
|
||||
target.closest('input, textarea, select')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const primaryAssetButton = target.closest(
|
||||
'.image-canvas-editor__asset-button',
|
||||
);
|
||||
if (target.closest('button') && !primaryAssetButton) {
|
||||
return;
|
||||
}
|
||||
const nextDrag: AssetPointerDragState = {
|
||||
pointerId: event.pointerId,
|
||||
assetId: asset.id,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
currentClientX: event.clientX,
|
||||
currentClientY: event.clientY,
|
||||
active: false,
|
||||
dropFolderId: null,
|
||||
};
|
||||
if (!primaryAssetButton) {
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(
|
||||
event.pointerId,
|
||||
);
|
||||
} catch {
|
||||
// 自动化环境可能没有 active pointer,拖拽状态仍可走 window 事件完成。
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
assetPointerDragRef.current = nextDrag;
|
||||
setAssetPointerDrag(nextDrag);
|
||||
}}
|
||||
onPointerEnter={(event) => {
|
||||
if (isAssetSelectionMode && event.buttons === 1) {
|
||||
setSelectedAssetIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
nextIds.add(asset.id);
|
||||
return nextIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
hasDataTransferType(
|
||||
event.dataTransfer,
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(asset.folderId);
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, 'Files')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget('assets');
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const movingAssetId = getDraggedAssetId(
|
||||
event.dataTransfer,
|
||||
);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
moveAssetToFolder(movingAssetId, asset.folderId);
|
||||
return;
|
||||
}
|
||||
if (!event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
addUploadedFiles(event.dataTransfer.files, {
|
||||
folderId: asset.folderId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{isAssetSelectionMode ? (
|
||||
<PlatformBatchActionToolbar
|
||||
className="image-canvas-editor__asset-batch-toolbar"
|
||||
label="素材批量操作"
|
||||
>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={toggleAllAssetsSelected}
|
||||
>
|
||||
{allSelectableAssetsSelected ? (
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
{selectedAssetIds.size > 0
|
||||
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
|
||||
: '全选'}
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="warning"
|
||||
size="sm"
|
||||
disabled={selectedAssetIds.size === 0}
|
||||
onClick={deleteSelectedAssets}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={closeAssetSelectionMode}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
</PlatformBatchActionToolbar>
|
||||
) : null}
|
||||
{assetMarquee ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-marquee"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
|
||||
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
|
||||
width: Math.abs(
|
||||
assetMarquee.currentX - assetMarquee.startX,
|
||||
),
|
||||
height: Math.abs(
|
||||
assetMarquee.currentY - assetMarquee.startY,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__layers-list">
|
||||
{layers
|
||||
.slice()
|
||||
.sort((left, right) => right.zIndex - left.zIndex)
|
||||
.map((layer) => (
|
||||
<SidebarMediaItem
|
||||
key={layer.id}
|
||||
title={layer.title}
|
||||
detail={[
|
||||
`${Math.round(layer.width)} x ${Math.round(layer.height)}`,
|
||||
layer.groupId ? '已打组' : null,
|
||||
layer.hidden ? '已隐藏' : null,
|
||||
layer.locked ? '已锁定' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
imageSrc={layer.src}
|
||||
imageAlt={`图层缩略图:${layer.title}`}
|
||||
selected={selectedLayerId === layer.id}
|
||||
primaryLabel={`选择图层${layer.title}`}
|
||||
onPrimaryClick={() => selectSingleLayer(layer.id)}
|
||||
rowClassName="image-canvas-editor__layer-row"
|
||||
primaryClassName="image-canvas-editor__layer-row-button"
|
||||
thumbnailClassName="image-canvas-editor__layer-row-thumb"
|
||||
metaClassName="image-canvas-editor__layer-row-meta"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!selectedLayerIds.includes(layer.id)) {
|
||||
selectSingleLayer(layer.id);
|
||||
}
|
||||
const position = resolveContextMenuPosition(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
'layer',
|
||||
);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu({
|
||||
kind: 'layer',
|
||||
layerId: layer.id,
|
||||
...position,
|
||||
canvasPoint: getCanvasPointFromClient(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
<ImageCanvasSidebarView
|
||||
activeSidebarPanel={activeSidebarPanel}
|
||||
assetListRef={assetListRef}
|
||||
uploadInputRef={uploadInputRef}
|
||||
assetPointerDragRef={assetPointerDragRef}
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
assets={assets}
|
||||
groupedAssets={groupedAssets}
|
||||
assetFolders={assetFolders}
|
||||
layers={layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayerIds={selectedLayerIds}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
assetMoveDropFolderId={assetMoveDropFolderId}
|
||||
pinnedAssetMoveFolderId={pinnedAssetMoveFolderId}
|
||||
creatingFolder={creatingFolder}
|
||||
newFolderName={newFolderName}
|
||||
renamingFolder={renamingFolder}
|
||||
renamingAsset={renamingAsset}
|
||||
allSelectableAssetsSelected={allSelectableAssetsSelected}
|
||||
assetMarquee={assetMarquee}
|
||||
setIsAssetSelectionMode={setIsAssetSelectionMode}
|
||||
setCreatingFolder={setCreatingFolder}
|
||||
setNewFolderName={setNewFolderName}
|
||||
setRenamingFolder={setRenamingFolder}
|
||||
setRenamingAsset={setRenamingAsset}
|
||||
setActiveUploadFolderId={setActiveUploadFolderId}
|
||||
setUploadTarget={setUploadTarget}
|
||||
setUploadDropTarget={setUploadDropTarget}
|
||||
setAssetPointerDrag={setAssetPointerDrag}
|
||||
setSelectedAssetIds={setSelectedAssetIds}
|
||||
setImageContextMenu={setImageContextMenu}
|
||||
setContextMenu={setContextMenu}
|
||||
onAssetMarqueePointerDown={handleAssetMarqueePointerDown}
|
||||
onAssetMarqueePointerMove={handleAssetMarqueePointerMove}
|
||||
onAssetMarqueePointerUp={handleAssetMarqueePointerUp}
|
||||
updateAssetMoveDropFolder={updateAssetMoveDropFolder}
|
||||
addUploadedFiles={addUploadedFiles}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
commitNewAssetFolder={commitNewAssetFolder}
|
||||
toggleAssetFolder={toggleAssetFolder}
|
||||
startRenamingFolder={startRenamingFolder}
|
||||
commitFolderRename={commitFolderRename}
|
||||
deleteAssetFolder={deleteAssetFolder}
|
||||
startRenamingAsset={startRenamingAsset}
|
||||
commitAssetRename={commitAssetRename}
|
||||
deleteUploadedAsset={deleteUploadedAsset}
|
||||
toggleAssetSelected={toggleAssetSelected}
|
||||
addAssetLayer={addAssetLayer}
|
||||
toggleAllAssetsSelected={toggleAllAssetsSelected}
|
||||
deleteSelectedAssets={deleteSelectedAssets}
|
||||
closeAssetSelectionMode={closeAssetSelectionMode}
|
||||
groupSelectedLayers={groupSelectedLayers}
|
||||
selectSingleLayer={selectSingleLayer}
|
||||
resolveContextMenuPosition={resolveContextMenuPosition}
|
||||
getCanvasPointFromClient={getCanvasPointFromClient}
|
||||
/>
|
||||
|
||||
<div className="image-canvas-editor__main">
|
||||
<div className="image-canvas-editor__topbar">
|
||||
|
||||
828
src/components/image-editor/ImageCanvasSidebarView.tsx
Normal file
828
src/components/image-editor/ImageCanvasSidebarView.tsx
Normal file
@@ -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<HTMLDivElement | null>;
|
||||
uploadInputRef: RefObject<HTMLInputElement | null>;
|
||||
assetPointerDragRef: { current: AssetPointerDragState | null };
|
||||
suppressAssetClickRef: { current: boolean };
|
||||
assets: EditorAsset[];
|
||||
groupedAssets: GroupedEditorAssetFolder[];
|
||||
assetFolders: EditorAssetFolder[];
|
||||
layers: CanvasLayer[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayerIds: string[];
|
||||
isAssetSelectionMode: boolean;
|
||||
selectedAssetIds: Set<string>;
|
||||
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<SetStateAction<boolean>>;
|
||||
setCreatingFolder: Dispatch<SetStateAction<boolean>>;
|
||||
setNewFolderName: Dispatch<SetStateAction<string>>;
|
||||
setRenamingFolder: Dispatch<
|
||||
SetStateAction<{ folderId: string; value: string } | null>
|
||||
>;
|
||||
setRenamingAsset: Dispatch<
|
||||
SetStateAction<{ assetId: string; value: string } | null>
|
||||
>;
|
||||
setActiveUploadFolderId: Dispatch<SetStateAction<string>>;
|
||||
setUploadTarget: Dispatch<SetStateAction<UploadTarget>>;
|
||||
setUploadDropTarget: Dispatch<SetStateAction<'canvas' | 'assets' | null>>;
|
||||
setAssetPointerDrag: Dispatch<SetStateAction<AssetPointerDragState | null>>;
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
setImageContextMenu: Dispatch<SetStateAction<ImageContextMenuState | null>>;
|
||||
setContextMenu: Dispatch<SetStateAction<CanvasContextMenuState | null>>;
|
||||
onAssetMarqueePointerDown: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueePointerMove: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
onAssetMarqueePointerUp: (
|
||||
event: ReactPointerEvent<HTMLDivElement>,
|
||||
) => void;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
commitNewAssetFolder: () => void | Promise<void>;
|
||||
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<CanvasContextMenuState, 'kind' | 'layerId' | 'canvasPoint'>;
|
||||
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 (
|
||||
<aside className="image-canvas-editor__sidebar" aria-label="图片资源栏">
|
||||
<div className="image-canvas-editor__sidebar-header">
|
||||
<div className="min-w-0">
|
||||
<h2 className="image-canvas-editor__sidebar-title">
|
||||
{activeSidebarPanel === 'assets' ? '素材' : '图层'}
|
||||
</h2>
|
||||
<div className="image-canvas-editor__sidebar-count">
|
||||
{activeSidebarPanel === 'assets' ? assets.length : layers.length}
|
||||
</div>
|
||||
</div>
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
<div className="image-canvas-editor__sidebar-header-actions">
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="素材选择模式"
|
||||
title="选择"
|
||||
icon={isAssetSelectionMode ? CheckSquare : Square}
|
||||
pressed={isAssetSelectionMode}
|
||||
onClick={() =>
|
||||
setIsAssetSelectionMode((currentMode) => !currentMode)
|
||||
}
|
||||
/>
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="新建素材文件夹"
|
||||
title="新建文件夹"
|
||||
icon={FolderPlus}
|
||||
onClick={() => setCreatingFolder(true)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
className="image-canvas-editor__icon-button"
|
||||
label="图层打组"
|
||||
title="打组"
|
||||
icon={FolderPlus}
|
||||
disabled={!selectedLayerId && selectedLayerIds.length === 0}
|
||||
onClick={groupSelectedLayers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
<div
|
||||
ref={assetListRef}
|
||||
className="image-canvas-editor__asset-list"
|
||||
onPointerDown={onAssetMarqueePointerDown}
|
||||
onPointerMove={onAssetMarqueePointerMove}
|
||||
onPointerUp={onAssetMarqueePointerUp}
|
||||
onPointerCancel={onAssetMarqueePointerUp}
|
||||
>
|
||||
{pinnedAssetMoveFolderId ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-sticky-target"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>
|
||||
{assetFolders.find(
|
||||
(folder) => folder.id === pinnedAssetMoveFolderId,
|
||||
)?.label ?? '目标文件夹'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{creatingFolder ? (
|
||||
<form
|
||||
className="image-canvas-editor__folder-create"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void commitNewAssetFolder();
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
aria-label="素材文件夹名称"
|
||||
value={newFolderName}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-create-input"
|
||||
onChange={(event) => setNewFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EditorIconButton
|
||||
type="submit"
|
||||
label="保存素材文件夹"
|
||||
icon={Check}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label="取消新建素材文件夹"
|
||||
icon={X}
|
||||
onClick={() => {
|
||||
setCreatingFolder(false);
|
||||
setNewFolderName('');
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
) : null}
|
||||
{groupedAssets.map((folder) => (
|
||||
<section
|
||||
key={folder.id}
|
||||
className={[
|
||||
'image-canvas-editor__asset-folder',
|
||||
assetMoveDropFolderId === folder.id
|
||||
? 'image-canvas-editor__asset-folder--move-target'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-label={folder.label}
|
||||
data-asset-folder-id={folder.id}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
hasDataTransferType(event.dataTransfer, ASSET_DRAG_MIME_TYPE)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(folder.id);
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, 'Files')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget('assets');
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const movingAssetId = getDraggedAssetId(event.dataTransfer);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
moveAssetToFolder(movingAssetId, folder.id);
|
||||
return;
|
||||
}
|
||||
if (!event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
addUploadedFiles(event.dataTransfer.files, {
|
||||
folderId: folder.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-header"
|
||||
data-asset-folder-header-id={folder.id}
|
||||
>
|
||||
<EditorIconButton
|
||||
label={`${folder.collapsed ? '展开' : '折叠'}${folder.label}`}
|
||||
title={folder.collapsed ? '展开' : '折叠'}
|
||||
icon={folder.collapsed ? ChevronRight : ChevronDown}
|
||||
expanded={!folder.collapsed}
|
||||
onClick={() => toggleAssetFolder(folder.id)}
|
||||
/>
|
||||
<Folder className="h-4 w-4" />
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名文件夹${folder.label}`}
|
||||
value={renamingFolder.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__folder-rename-input"
|
||||
onChange={(event) =>
|
||||
setRenamingFolder({
|
||||
folderId: folder.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitFolderRename(folder);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setRenamingFolder(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span>{folder.label}</span>
|
||||
)}
|
||||
<span>{folder.assets.length}</span>
|
||||
{renamingFolder?.folderId === folder.id ? (
|
||||
<>
|
||||
<EditorIconButton
|
||||
label={`保存文件夹${folder.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitFolderRename(folder)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名文件夹${folder.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingFolder(null)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EditorIconButton
|
||||
label={`重命名文件夹${folder.label}`}
|
||||
title="重命名"
|
||||
icon={PencilLine}
|
||||
onClick={() => startRenamingFolder(folder)}
|
||||
/>
|
||||
)}
|
||||
{!folder.systemDefault ? (
|
||||
<EditorIconButton
|
||||
label={`删除文件夹${folder.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteAssetFolder(folder)}
|
||||
/>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label={`上传到${folder.label}`}
|
||||
title="上传"
|
||||
icon={ImagePlus}
|
||||
onClick={() => {
|
||||
setActiveUploadFolderId(folder.id);
|
||||
setUploadTarget('asset');
|
||||
uploadInputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="image-canvas-editor__asset-folder-list"
|
||||
hidden={folder.collapsed}
|
||||
>
|
||||
{folder.assets.map((asset) => {
|
||||
const isRenaming = renamingAsset?.assetId === asset.id;
|
||||
const isUploadingAsset = asset.uploadStatus === 'uploading';
|
||||
const isFailedUpload = asset.uploadStatus === 'failed';
|
||||
const uploadProgress = clamp(
|
||||
asset.uploadProgress ?? 0,
|
||||
0,
|
||||
100,
|
||||
);
|
||||
const titleNode = isRenaming ? (
|
||||
<PlatformTextField
|
||||
aria-label={`重命名素材${asset.label}`}
|
||||
value={renamingAsset.value}
|
||||
autoFocus
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__asset-rename-input"
|
||||
onChange={(event) =>
|
||||
setRenamingAsset({
|
||||
assetId: asset.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commitAssetRename(asset);
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setRenamingAsset(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : undefined;
|
||||
const actions = isUploadingAsset ? (
|
||||
<div className="image-canvas-editor__asset-upload-status">
|
||||
<span>{asset.uploadMessage ?? '上传中'}</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
) : isRenaming ? (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`保存素材${asset.label}名称`}
|
||||
title="保存"
|
||||
icon={Check}
|
||||
onClick={() => commitAssetRename(asset)}
|
||||
/>
|
||||
<EditorIconButton
|
||||
label={`取消重命名素材${asset.label}`}
|
||||
title="取消"
|
||||
icon={X}
|
||||
onClick={() => setRenamingAsset(null)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__asset-actions">
|
||||
<EditorIconButton
|
||||
label={`重命名素材${asset.label}`}
|
||||
title="重命名"
|
||||
icon={Pencil}
|
||||
onClick={() => startRenamingAsset(asset)}
|
||||
/>
|
||||
{asset.sourceKind === 'uploaded' ? (
|
||||
<EditorIconButton
|
||||
label={`删除素材${asset.label}`}
|
||||
title="删除"
|
||||
icon={Trash2}
|
||||
onClick={() => deleteUploadedAsset(asset)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={asset.id} data-asset-id={asset.id}>
|
||||
<SidebarMediaItem
|
||||
title={asset.label}
|
||||
detail={`${asset.width} x ${asset.height}`}
|
||||
imageSrc={asset.src}
|
||||
imageAlt={`素材:${asset.label}`}
|
||||
primaryLabel={
|
||||
isUploadingAsset
|
||||
? `上传中${asset.label}`
|
||||
: isFailedUpload
|
||||
? `上传失败${asset.label}`
|
||||
: isAssetSelectionMode
|
||||
? `选择素材${asset.label}`
|
||||
: `添加${asset.label}`
|
||||
}
|
||||
onPrimaryClick={() => {
|
||||
if (isUploadingAsset || isFailedUpload) {
|
||||
return;
|
||||
}
|
||||
if (suppressAssetClickRef.current) {
|
||||
return;
|
||||
}
|
||||
if (isAssetSelectionMode) {
|
||||
toggleAssetSelected(asset.id);
|
||||
return;
|
||||
}
|
||||
addAssetLayer(asset);
|
||||
}}
|
||||
selected={selectedAssetIds.has(asset.id)}
|
||||
rowClassName={[
|
||||
'image-canvas-editor__asset-row',
|
||||
isUploadingAsset
|
||||
? 'image-canvas-editor__asset-row--uploading'
|
||||
: '',
|
||||
isFailedUpload
|
||||
? 'image-canvas-editor__asset-row--upload-failed'
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
primaryClassName="image-canvas-editor__asset-button"
|
||||
thumbnailClassName="image-canvas-editor__asset-thumb"
|
||||
metaClassName="image-canvas-editor__asset-meta"
|
||||
titleNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
<span>{asset.label}</span>
|
||||
) : (
|
||||
titleNode
|
||||
)
|
||||
}
|
||||
actions={actions}
|
||||
draggable={!isRenaming && !isUploadingAsset && !isFailedUpload}
|
||||
previewOverlay={
|
||||
isUploadingAsset ? (
|
||||
<div className="image-canvas-editor__asset-upload-overlay">
|
||||
<span>上传中</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
footerNode={
|
||||
isUploadingAsset || isFailedUpload ? (
|
||||
<div className="image-canvas-editor__asset-upload-progress">
|
||||
<div>
|
||||
<span>
|
||||
{isFailedUpload
|
||||
? (asset.uploadMessage ?? '上传失败')
|
||||
: (asset.uploadMessage ?? '上传中')}
|
||||
</span>
|
||||
<strong>{Math.round(uploadProgress)}%</strong>
|
||||
</div>
|
||||
<progress
|
||||
aria-label={`素材${asset.label}上传进度`}
|
||||
max={100}
|
||||
value={uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
if (isRenaming || isUploadingAsset || isFailedUpload) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
assetPointerDragRef.current?.assetId === asset.id
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData(
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
asset.id,
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', asset.label);
|
||||
event.dataTransfer.setData('text/uri-list', asset.src);
|
||||
updateAssetMoveDropFolder(asset.folderId);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
const target = event.target as HTMLElement;
|
||||
if (isAssetSelectionMode) {
|
||||
if (target.closest('button')) {
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isRenaming ||
|
||||
isUploadingAsset ||
|
||||
isFailedUpload ||
|
||||
target.closest('input, textarea, select')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const primaryAssetButton = target.closest(
|
||||
'.image-canvas-editor__asset-button',
|
||||
);
|
||||
if (target.closest('button') && !primaryAssetButton) {
|
||||
return;
|
||||
}
|
||||
const nextDrag: AssetPointerDragState = {
|
||||
pointerId: event.pointerId,
|
||||
assetId: asset.id,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
currentClientX: event.clientX,
|
||||
currentClientY: event.clientY,
|
||||
active: false,
|
||||
dropFolderId: null,
|
||||
};
|
||||
if (!primaryAssetButton) {
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(
|
||||
event.pointerId,
|
||||
);
|
||||
} catch {
|
||||
// 自动化环境可能没有 active pointer,拖拽状态仍可走 window 事件完成。
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
event.stopPropagation();
|
||||
assetPointerDragRef.current = nextDrag;
|
||||
setAssetPointerDrag(nextDrag);
|
||||
}}
|
||||
onPointerEnter={(event) => {
|
||||
if (isAssetSelectionMode && event.buttons === 1) {
|
||||
setSelectedAssetIds((currentIds) => {
|
||||
const nextIds = new Set(currentIds);
|
||||
nextIds.add(asset.id);
|
||||
return nextIds;
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (
|
||||
hasDataTransferType(
|
||||
event.dataTransfer,
|
||||
ASSET_DRAG_MIME_TYPE,
|
||||
)
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(asset.folderId);
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
return;
|
||||
}
|
||||
if (hasDataTransferType(event.dataTransfer, 'Files')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget('assets');
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const movingAssetId = getDraggedAssetId(
|
||||
event.dataTransfer,
|
||||
);
|
||||
if (movingAssetId) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
moveAssetToFolder(movingAssetId, asset.folderId);
|
||||
return;
|
||||
}
|
||||
if (!event.dataTransfer.files.length) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
addUploadedFiles(event.dataTransfer.files, {
|
||||
folderId: asset.folderId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{isAssetSelectionMode ? (
|
||||
<PlatformBatchActionToolbar
|
||||
className="image-canvas-editor__asset-batch-toolbar"
|
||||
label="素材批量操作"
|
||||
>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={toggleAllAssetsSelected}
|
||||
>
|
||||
{allSelectableAssetsSelected ? (
|
||||
<CheckSquare className="h-4 w-4" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
{selectedAssetIds.size > 0
|
||||
? `${allSelectableAssetsSelected ? '取消全选' : '全选'} · 已选 ${selectedAssetIds.size}`
|
||||
: '全选'}
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="warning"
|
||||
size="sm"
|
||||
disabled={selectedAssetIds.size === 0}
|
||||
onClick={deleteSelectedAssets}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
size="sm"
|
||||
onClick={closeAssetSelectionMode}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
</PlatformBatchActionToolbar>
|
||||
) : null}
|
||||
{assetMarquee ? (
|
||||
<div
|
||||
className="image-canvas-editor__asset-marquee"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left: Math.min(assetMarquee.startX, assetMarquee.currentX),
|
||||
top: Math.min(assetMarquee.startY, assetMarquee.currentY),
|
||||
width: Math.abs(assetMarquee.currentX - assetMarquee.startX),
|
||||
height: Math.abs(assetMarquee.currentY - assetMarquee.startY),
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="image-canvas-editor__layers-list">
|
||||
{layers
|
||||
.slice()
|
||||
.sort((left, right) => right.zIndex - left.zIndex)
|
||||
.map((layer) => (
|
||||
<SidebarMediaItem
|
||||
key={layer.id}
|
||||
title={layer.title}
|
||||
detail={[
|
||||
`${Math.round(layer.width)} x ${Math.round(layer.height)}`,
|
||||
layer.groupId ? '已打组' : null,
|
||||
layer.hidden ? '已隐藏' : null,
|
||||
layer.locked ? '已锁定' : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
imageSrc={layer.src}
|
||||
imageAlt={`图层缩略图:${layer.title}`}
|
||||
selected={selectedLayerId === layer.id}
|
||||
primaryLabel={`选择图层${layer.title}`}
|
||||
onPrimaryClick={() => selectSingleLayer(layer.id)}
|
||||
rowClassName="image-canvas-editor__layer-row"
|
||||
primaryClassName="image-canvas-editor__layer-row-button"
|
||||
thumbnailClassName="image-canvas-editor__layer-row-thumb"
|
||||
metaClassName="image-canvas-editor__layer-row-meta"
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!selectedLayerIds.includes(layer.id)) {
|
||||
selectSingleLayer(layer.id);
|
||||
}
|
||||
const position = resolveContextMenuPosition(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
'layer',
|
||||
);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu({
|
||||
kind: 'layer',
|
||||
layerId: layer.id,
|
||||
...position,
|
||||
canvasPoint: getCanvasPointFromClient(
|
||||
event.clientX,
|
||||
event.clientY,
|
||||
),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user