拆分图片画布编辑器侧栏视图

抽出素材和图层左侧整合面板为 ImageCanvasSidebarView

保留上传、登录、拖到画布和持久化状态机在主视图

更新前端拆分计划和 TRACKING 验证记录
This commit is contained in:
2026-06-17 02:17:30 +08:00
parent 1f5605331f
commit f789499c36
4 changed files with 900 additions and 667 deletions

View File

@@ -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画布工具栏` 保持可见。

View File

@@ -34,9 +34,17 @@
- 承载画布素材导出的底层规则:文件名清理、日期格式、图片去重 key、Data URL 转 Blob、Blob 读取和图层导出元数据。
- ZIP 组包和下载触发仍留在主视图,作为 UI 状态编排的一部分。
## 第二阶段模块
- `ImageCanvasSidebarView.tsx`
- 承载素材 / 图层共用左侧整合面板的 JSX包括素材文件夹、新建 / 折叠 / 重命名 / 删除、上传入口、素材选择模式、框选层、批量删除、素材拖到文件夹和图层列表。
- 继续通过 props 调用主视图状态机,不接管上传、登录弹窗、持久化、拖到画布的坐标换算和图层历史记录。
- 保持“素材”和“图层”同一侧栏切换的 Lovart 式布局,不恢复右侧独立图层栏或左侧竖向工具栏。
第二阶段以后主视图仍是画布编排入口。继续拆分前应优先选择能形成稳定边界的深模块避免把上传链路、DataTransfer、画布坐标和历史快照拆成互相回调的小碎片。
## 后续阶段
- `ImageCanvasSidebarView`:素材 / 图层共用侧栏,等模型层稳定后再拆。
- `ImageCanvasStageView`:画布 viewport、图层渲染、右键菜单和生成占位框等交互回归覆盖更强后再拆。
- `ImageCanvasGenerationDock`:底部 AI 工具栏和生成面板族,等生成对象状态机进一步收口后再拆。

View File

@@ -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">

View 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>
);
}