补齐图片编辑器素材和画布搜索
普通上传默认落入上传素材文件夹 缺少上传素材文件夹时首次上传前自动创建 素材库和图层侧栏增加前缀优先搜索 更新图片画布技术文档和定向测试
This commit is contained in:
@@ -105,12 +105,12 @@
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 快速编辑的额外参考图同样必须在前端读取成图片 Data URL 后提交;后端 `referenceImageSrcs` 上限为 9 张,承载 8 张额外参考图加 1 张原图。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入默认素材文件夹并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入默认文件夹并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。
|
||||
- 素材文件夹可以新建、折叠、重命名和删除;删除普通文件夹后,其素材移动到“项目素材”。普通上传默认落入“上传素材”文件夹;素材库缺少该文件夹时,前端在首次普通上传前创建一次并复用,拖到指定文件夹或点击指定文件夹上传时仍进入目标文件夹。
|
||||
- 上传按钮和拖拽上传都支持多文件;底部工具栏的上传入口选择文件后直接进入“上传素材”并在当前画布视口中心创建画布图层,素材栏文件夹内的上传入口只写入对应素材文件夹、不自动入画布;拖到文件夹或该文件夹内素材时进入目标文件夹;拖到画布时进入“上传素材”并在投放点创建画布图层。上传图片必须在创建占位素材、画布图层和账号级素材记录前先读取原图 Resolution,图层宽高、`originalWidth/originalHeight` 和素材库 `width/height` 都使用图片本身尺寸;仅在无法解析尺寸时才使用上传兜底尺寸。
|
||||
- 音频 / 视频素材卡和画布媒体图层必须提供稳定的非文字视觉预览:优先使用 `thumbnailSrc` / 视频 `poster`,没有真实首帧或音频封面时使用由媒体类型、素材名和地址派生的确定性视觉底图。视频图层使用原生 `<video controls preload="metadata" playsInline>` 播放,外层图层仍承接选择和拖拽语义;音频图层播放前继续通过 `/api/assets/read-url` 换签。画布素材导出按 `mediaType` 保留真实媒体格式:图片进入 `images/`,音频 / 视频进入 `media/`,文件扩展名从响应 MIME、`objectKey` 或源 URL 推断,不得把音频 / 视频导出成 PNG。
|
||||
- `生成角色动作` 的完成结果同样按视频素材处理:图层主 `src` 使用后端返回的 `previewVideoPath`,`mediaType` 固定为 `video`,`assetKind` 固定为 `character-animation`,首个透明帧只写入 `thumbnailSrc` 供 poster 和后续动作源使用。下载和 ZIP 导出必须因此得到 mp4/webm/mov 等真实视频文件,不能回退为首帧 PNG;后端抽帧、绿幕去背和帧素材落盘流程仍完整保留。
|
||||
- 素材面板支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 素材面板支持按素材名、文件夹名、生成信息、模型、任务和媒体类型搜索,并支持选择模式框选,一次选中多个素材,并可批量移动或删除上传素材。
|
||||
- 图层面板支持按图层名、生成信息、模型、任务和媒体类型搜索;支持选择多个图层后创建图层组,组名和 groupId 随画布布局保存。
|
||||
- 小地图支持拖拽视口框,拖动时画布 viewport 跟随移动。
|
||||
- 鼠标滚轮默认垂直滚动画布视口;按住 Ctrl / Cmd 滚轮才缩放画布,并阻止浏览器页面缩放。
|
||||
- 工程刷新后能从后端恢复资源、图层布局和 viewport。
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ImageCanvasAssetRowView } from './ImageCanvasAssetRowView';
|
||||
import type {
|
||||
GroupedEditorAssetFolder,
|
||||
UploadFilesOptions,
|
||||
UploadRequestOptions,
|
||||
} from './ImageCanvasAssetLibraryPanelView';
|
||||
|
||||
type ImageCanvasAssetFolderSectionViewProps = {
|
||||
@@ -50,7 +51,7 @@ type ImageCanvasAssetFolderSectionViewProps = {
|
||||
setSelectedAssetIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
toggleAssetFolder: (folderId: string) => void;
|
||||
startRenamingFolder: (folder: EditorAssetFolder) => void;
|
||||
@@ -220,7 +221,7 @@ export function ImageCanvasAssetFolderSectionView({
|
||||
icon={ImagePlus}
|
||||
onClick={() => {
|
||||
setActiveUploadFolderId(folder.id);
|
||||
requestUpload('asset');
|
||||
requestUpload('asset', { folderId: folder.id });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createAssetMarqueeSelectionRect,
|
||||
createLocalAssetFolder,
|
||||
deleteAssetFolderLocally,
|
||||
filterGroupedAssetsBySearch,
|
||||
getSelectableAssets,
|
||||
groupAssetsByFolder,
|
||||
moveAssetToFolderLocally,
|
||||
@@ -22,7 +23,9 @@ import {
|
||||
resolveAllAssetSelection,
|
||||
resolveAssetFolderIdFromPoint,
|
||||
resolveDefaultAssetFolder,
|
||||
resolvePreferredAssetUploadFolder,
|
||||
resolvePinnedAssetMoveFolderId,
|
||||
resolveUploadAssetFolder,
|
||||
selectUploadedAssetsInRect,
|
||||
toggleAssetFolderCollapsed,
|
||||
toggleAssetSelection,
|
||||
@@ -86,6 +89,48 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters grouped assets by folder and asset search text', () => {
|
||||
const groupedAssets = groupAssetsByFolder(
|
||||
[
|
||||
createFolder(),
|
||||
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
|
||||
],
|
||||
[
|
||||
createAsset({ id: 'asset-a', label: '草地角色', folderId: 'project' }),
|
||||
createAsset({
|
||||
id: 'asset-b',
|
||||
label: '音效',
|
||||
folderId: 'folder-uploaded',
|
||||
model: 'audio1.0',
|
||||
}),
|
||||
createAsset({
|
||||
id: 'asset-c',
|
||||
label: '角色音效',
|
||||
folderId: 'folder-uploaded',
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(
|
||||
filterGroupedAssetsBySearch(groupedAssets, 'audio').map((folder) => ({
|
||||
id: folder.id,
|
||||
assets: folder.assets.map((asset) => asset.id),
|
||||
})),
|
||||
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b'] }]);
|
||||
expect(
|
||||
filterGroupedAssetsBySearch(groupedAssets, '音').map((folder) => ({
|
||||
id: folder.id,
|
||||
assets: folder.assets.map((asset) => asset.id),
|
||||
})),
|
||||
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
|
||||
expect(
|
||||
filterGroupedAssetsBySearch(groupedAssets, '上传').map((folder) => ({
|
||||
id: folder.id,
|
||||
assets: folder.assets.map((asset) => asset.id),
|
||||
})),
|
||||
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
|
||||
});
|
||||
|
||||
it('renames assets, toggles folders and creates local folders', () => {
|
||||
expect(renameAssetById([createAsset()], 'asset-a', '新名字')[0]).toMatchObject(
|
||||
{ label: '新名字' },
|
||||
@@ -147,6 +192,13 @@ describe('ImageCanvasAssetLibraryModel', () => {
|
||||
'asset-b',
|
||||
]);
|
||||
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
|
||||
expect(
|
||||
resolveUploadAssetFolder([
|
||||
createFolder(),
|
||||
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
|
||||
])?.id,
|
||||
).toBe('folder-uploaded');
|
||||
expect(resolvePreferredAssetUploadFolder(folders)?.id).toBe('project');
|
||||
expect(
|
||||
deleteAssetFolderLocally({
|
||||
folders,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AssetMarqueeState,
|
||||
CanvasLayer,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
@@ -25,6 +26,8 @@ export type GroupedAssetFolder = EditorAssetFolder & {
|
||||
assets: EditorAsset[];
|
||||
};
|
||||
|
||||
export const EDITOR_UPLOAD_ASSET_FOLDER_LABEL = '上传素材';
|
||||
|
||||
function isPointInRect(point: ClientPoint, rect: ClientRectLike) {
|
||||
return (
|
||||
point.clientX >= rect.left &&
|
||||
@@ -183,6 +186,104 @@ export function groupAssetsByFolder(
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(query: string) {
|
||||
return query.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => value?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
function startsWithSearchText(query: string, values: Array<string | null | undefined>) {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
return values.some((value) => value?.trim().toLowerCase().startsWith(query));
|
||||
}
|
||||
|
||||
function buildAssetSearchValues(asset: EditorAsset) {
|
||||
return [
|
||||
asset.label,
|
||||
asset.prompt,
|
||||
asset.actualPrompt,
|
||||
asset.model,
|
||||
asset.provider,
|
||||
asset.taskId,
|
||||
asset.assetKind,
|
||||
asset.mediaType,
|
||||
];
|
||||
}
|
||||
|
||||
function buildLayerSearchValues(layer: CanvasLayer) {
|
||||
return [
|
||||
layer.title,
|
||||
layer.prompt,
|
||||
layer.actualPrompt,
|
||||
layer.model,
|
||||
layer.provider,
|
||||
layer.taskId,
|
||||
layer.assetKind,
|
||||
layer.mediaType,
|
||||
];
|
||||
}
|
||||
|
||||
function assetMatchesSearch(asset: EditorAsset, query: string) {
|
||||
return matchesSearchText(query, buildAssetSearchValues(asset));
|
||||
}
|
||||
|
||||
function assetStartsWithSearch(asset: EditorAsset, query: string) {
|
||||
return startsWithSearchText(query, buildAssetSearchValues(asset));
|
||||
}
|
||||
|
||||
export function layerMatchesSearch(layer: CanvasLayer, query: string) {
|
||||
return matchesSearchText(query, buildLayerSearchValues(layer));
|
||||
}
|
||||
|
||||
export function layerStartsWithSearch(layer: CanvasLayer, query: string) {
|
||||
return startsWithSearchText(query, buildLayerSearchValues(layer));
|
||||
}
|
||||
|
||||
export function filterGroupedAssetsBySearch(
|
||||
groupedAssets: GroupedAssetFolder[],
|
||||
query: string,
|
||||
) {
|
||||
const normalizedQuery = normalizeSearchQuery(query);
|
||||
if (!normalizedQuery) {
|
||||
return groupedAssets;
|
||||
}
|
||||
|
||||
return groupedAssets
|
||||
.map((folder) => {
|
||||
const folderMatches =
|
||||
startsWithSearchText(normalizedQuery, [folder.label]) ||
|
||||
matchesSearchText(normalizedQuery, [folder.label]);
|
||||
const prefixAssets = folder.assets.filter((asset) =>
|
||||
assetStartsWithSearch(asset, normalizedQuery),
|
||||
);
|
||||
const matchingAssets = folder.assets.filter((asset) =>
|
||||
assetMatchesSearch(asset, normalizedQuery),
|
||||
);
|
||||
return {
|
||||
...folder,
|
||||
assets: folderMatches
|
||||
? folder.assets
|
||||
: [
|
||||
...prefixAssets,
|
||||
...matchingAssets.filter(
|
||||
(asset) =>
|
||||
!prefixAssets.some(
|
||||
(prefixAsset) => prefixAsset.id === asset.id,
|
||||
),
|
||||
),
|
||||
],
|
||||
};
|
||||
})
|
||||
.filter((folder) => folder.assets.length > 0);
|
||||
}
|
||||
|
||||
export function getSelectableAssets(assets: EditorAsset[]) {
|
||||
return assets.filter((asset) => asset.sourceKind === 'uploaded');
|
||||
}
|
||||
@@ -306,6 +407,20 @@ export function resolveDefaultAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUploadAssetFolder(assetFolders: EditorAssetFolder[]) {
|
||||
return (
|
||||
assetFolders.find(
|
||||
(folder) => folder.label.trim() === EDITOR_UPLOAD_ASSET_FOLDER_LABEL,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePreferredAssetUploadFolder(
|
||||
assetFolders: EditorAssetFolder[],
|
||||
) {
|
||||
return resolveUploadAssetFolder(assetFolders) ?? resolveDefaultAssetFolder(assetFolders);
|
||||
}
|
||||
|
||||
export function deleteAssetFolderLocally({
|
||||
folders,
|
||||
assets,
|
||||
|
||||
@@ -36,6 +36,11 @@ export type UploadFilesOptions = {
|
||||
addToCanvas?: boolean;
|
||||
};
|
||||
|
||||
export type UploadRequestOptions = {
|
||||
folderId?: string;
|
||||
addToCanvas?: boolean;
|
||||
};
|
||||
|
||||
export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
assetListRef: RefObject<HTMLDivElement | null>;
|
||||
assetPointerDragRef: { current: AssetPointerDragState | null };
|
||||
@@ -75,7 +80,7 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
|
||||
) => void;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
commitNewAssetFolder: () => void | Promise<void>;
|
||||
toggleAssetFolder: (folderId: string) => void;
|
||||
|
||||
@@ -226,6 +226,37 @@ describe('ImageCanvasSidebarView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('filters assets from the sidebar search field', () => {
|
||||
const folder = createFolder();
|
||||
render(
|
||||
<ImageCanvasSidebarView
|
||||
{...createSidebarProps({
|
||||
assets: [
|
||||
createAsset({ id: 'asset-a', label: '账号素材A' }),
|
||||
createAsset({ id: 'asset-b', label: '火焰按钮' }),
|
||||
],
|
||||
groupedAssets: [
|
||||
{
|
||||
...folder,
|
||||
assets: [
|
||||
createAsset({ id: 'asset-a', label: '账号素材A' }),
|
||||
createAsset({ id: 'asset-b', label: '火焰按钮' }),
|
||||
],
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
fireEvent.change(within(sidebar).getByLabelText('搜索素材'), {
|
||||
target: { value: '火焰' },
|
||||
});
|
||||
|
||||
expect(within(sidebar).getByRole('button', { name: '添加火焰按钮' })).toBeTruthy();
|
||||
expect(within(sidebar).queryByRole('button', { name: '添加账号素材A' })).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the layer panel with grouped and locked layer details', () => {
|
||||
render(
|
||||
<ImageCanvasSidebarView
|
||||
@@ -255,6 +286,32 @@ describe('ImageCanvasSidebarView', () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('filters canvas layers from the sidebar search field', () => {
|
||||
render(
|
||||
<ImageCanvasSidebarView
|
||||
{...createSidebarProps({
|
||||
activeSidebarPanel: 'layers',
|
||||
layers: [
|
||||
createLayer({ id: 'layer-a', title: '角色草图', zIndex: 3 }),
|
||||
createLayer({ id: 'layer-b', title: '背景山脉', zIndex: 2 }),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
|
||||
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
|
||||
fireEvent.change(within(sidebar).getByLabelText('搜索画布图层'), {
|
||||
target: { value: '背景' },
|
||||
});
|
||||
|
||||
expect(
|
||||
within(sidebar).getByRole('button', { name: '选择图层背景山脉' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(sidebar).queryByRole('button', { name: '选择图层角色草图' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
|
||||
render(<SidebarTabsHarness />);
|
||||
|
||||
|
||||
@@ -9,12 +9,20 @@ import type {
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import {
|
||||
ImageCanvasAssetLibraryPanelView,
|
||||
type GroupedEditorAssetFolder,
|
||||
type UploadFilesOptions,
|
||||
type UploadRequestOptions,
|
||||
} from './ImageCanvasAssetLibraryPanelView';
|
||||
import {
|
||||
filterGroupedAssetsBySearch,
|
||||
layerMatchesSearch,
|
||||
layerStartsWithSearch,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
AssetMarqueeState,
|
||||
@@ -78,7 +86,7 @@ export type ImageCanvasSidebarViewProps = {
|
||||
) => void;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
addUploadedFiles: (files: FileList | File[], options?: UploadFilesOptions) => void;
|
||||
requestUpload: (target: UploadTarget) => void;
|
||||
requestUpload: (target: UploadTarget, options?: UploadRequestOptions) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
commitNewAssetFolder: () => void | Promise<void>;
|
||||
toggleAssetFolder: (folderId: string) => void;
|
||||
@@ -111,7 +119,6 @@ export function ImageCanvasSidebarView({
|
||||
assetListRef,
|
||||
assetPointerDragRef,
|
||||
suppressAssetClickRef,
|
||||
assets,
|
||||
groupedAssets,
|
||||
assetFolders,
|
||||
layers,
|
||||
@@ -163,6 +170,38 @@ export function ImageCanvasSidebarView({
|
||||
resolveContextMenuPosition,
|
||||
getCanvasPointFromClient,
|
||||
}: ImageCanvasSidebarViewProps) {
|
||||
const [assetSearchQuery, setAssetSearchQuery] = useState('');
|
||||
const [layerSearchQuery, setLayerSearchQuery] = useState('');
|
||||
const filteredGroupedAssets = useMemo(
|
||||
() => filterGroupedAssetsBySearch(groupedAssets, assetSearchQuery),
|
||||
[assetSearchQuery, groupedAssets],
|
||||
);
|
||||
const filteredAssetsCount = useMemo(
|
||||
() =>
|
||||
filteredGroupedAssets.reduce(
|
||||
(totalCount, folder) => totalCount + folder.assets.length,
|
||||
0,
|
||||
),
|
||||
[filteredGroupedAssets],
|
||||
);
|
||||
const normalizedLayerSearchQuery = layerSearchQuery.trim().toLowerCase();
|
||||
const filteredLayers = useMemo(
|
||||
() =>
|
||||
normalizedLayerSearchQuery
|
||||
? [
|
||||
...layers.filter((layer) =>
|
||||
layerStartsWithSearch(layer, normalizedLayerSearchQuery),
|
||||
),
|
||||
...layers.filter(
|
||||
(layer) =>
|
||||
!layerStartsWithSearch(layer, normalizedLayerSearchQuery) &&
|
||||
layerMatchesSearch(layer, normalizedLayerSearchQuery),
|
||||
),
|
||||
]
|
||||
: layers,
|
||||
[layers, normalizedLayerSearchQuery],
|
||||
);
|
||||
|
||||
if (!activeSidebarPanel) {
|
||||
return null;
|
||||
}
|
||||
@@ -175,7 +214,9 @@ export function ImageCanvasSidebarView({
|
||||
{activeSidebarPanel === 'assets' ? '素材' : '图层'}
|
||||
</h2>
|
||||
<div className="image-canvas-editor__sidebar-count">
|
||||
{activeSidebarPanel === 'assets' ? assets.length : layers.length}
|
||||
{activeSidebarPanel === 'assets'
|
||||
? filteredAssetsCount
|
||||
: filteredLayers.length}
|
||||
</div>
|
||||
</div>
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
@@ -210,12 +251,34 @@ export function ImageCanvasSidebarView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="image-canvas-editor__sidebar-search">
|
||||
<PlatformTextField
|
||||
aria-label={
|
||||
activeSidebarPanel === 'assets' ? '搜索素材' : '搜索画布图层'
|
||||
}
|
||||
placeholder={activeSidebarPanel === 'assets' ? '搜索素材' : '搜索图层'}
|
||||
value={
|
||||
activeSidebarPanel === 'assets' ? assetSearchQuery : layerSearchQuery
|
||||
}
|
||||
size="xs"
|
||||
density="compact"
|
||||
className="image-canvas-editor__sidebar-search-input"
|
||||
onChange={(event) => {
|
||||
if (activeSidebarPanel === 'assets') {
|
||||
setAssetSearchQuery(event.target.value);
|
||||
} else {
|
||||
setLayerSearchQuery(event.target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activeSidebarPanel === 'assets' ? (
|
||||
<ImageCanvasAssetLibraryPanelView
|
||||
assetListRef={assetListRef}
|
||||
assetPointerDragRef={assetPointerDragRef}
|
||||
suppressAssetClickRef={suppressAssetClickRef}
|
||||
groupedAssets={groupedAssets}
|
||||
groupedAssets={filteredGroupedAssets}
|
||||
assetFolders={assetFolders}
|
||||
isAssetSelectionMode={isAssetSelectionMode}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
@@ -258,7 +321,7 @@ export function ImageCanvasSidebarView({
|
||||
/>
|
||||
) : (
|
||||
<ImageCanvasLayerPanelView
|
||||
layers={layers}
|
||||
layers={filteredLayers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayerIds={selectedLayerIds}
|
||||
setImageContextMenu={setImageContextMenu}
|
||||
|
||||
@@ -105,6 +105,16 @@ describe('ImageCanvasUploadModel', () => {
|
||||
activeUploadFolderId: 'characters',
|
||||
}),
|
||||
).toBe('project');
|
||||
expect(
|
||||
resolveUploadFolderId({
|
||||
assetFolders: [
|
||||
...folders,
|
||||
createFolder({ id: 'uploaded', label: '上传素材', systemDefault: false }),
|
||||
],
|
||||
activeUploadFolderId: 'characters',
|
||||
preferUploadFolder: true,
|
||||
}),
|
||||
).toBe('uploaded');
|
||||
});
|
||||
|
||||
it('creates uploading asset placeholders', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
GenerateDialogState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { resolvePreferredAssetUploadFolder } from './ImageCanvasAssetLibraryModel';
|
||||
import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel';
|
||||
|
||||
type CanvasSize = { width: number; height: number };
|
||||
@@ -73,12 +74,19 @@ export function resolveUploadFolderId({
|
||||
assetFolders,
|
||||
requestedFolderId,
|
||||
activeUploadFolderId,
|
||||
preferUploadFolder = false,
|
||||
}: {
|
||||
assetFolders: EditorAssetFolder[];
|
||||
requestedFolderId?: string;
|
||||
activeUploadFolderId: string;
|
||||
preferUploadFolder?: boolean;
|
||||
}) {
|
||||
const targetFolderId = requestedFolderId ?? activeUploadFolderId;
|
||||
const targetFolderId =
|
||||
requestedFolderId ??
|
||||
(preferUploadFolder
|
||||
? resolvePreferredAssetUploadFolder(assetFolders)?.id
|
||||
: activeUploadFolderId) ??
|
||||
activeUploadFolderId;
|
||||
return assetFolders.some((folder) => folder.id === targetFolderId)
|
||||
? targetFolderId
|
||||
: 'project';
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
||||
|
||||
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorSeedanceReferenceFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
@@ -35,6 +36,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
return {
|
||||
...actual,
|
||||
createEditorAsset: createEditorAssetMock,
|
||||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
};
|
||||
});
|
||||
@@ -338,6 +340,12 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
objectKey: 'object-key-uploaded',
|
||||
assetObjectId: 'asset-object-uploaded',
|
||||
}));
|
||||
createEditorAssetFolderMock.mockResolvedValue({
|
||||
folderId: 'folder-uploaded',
|
||||
label: '上传素材',
|
||||
collapsed: false,
|
||||
systemDefault: false,
|
||||
});
|
||||
createEditorProjectResourceMock.mockImplementation(
|
||||
async (projectId, input) => ({
|
||||
resourceId: `resource-${input.objectKey ?? input.imageSrc.slice(0, 8)}`,
|
||||
@@ -421,9 +429,10 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'persisted-上传素材.png:上传素材.png:project:ready:-',
|
||||
'persisted-上传素材.png:上传素材.png:folder-uploaded:ready:-',
|
||||
);
|
||||
});
|
||||
expect(createEditorAssetFolderMock).toHaveBeenCalledWith('上传素材', 101);
|
||||
});
|
||||
|
||||
it('opens login instead of the asset file picker when protected data is unavailable', () => {
|
||||
@@ -483,7 +492,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'upload-1:画布素材.png:project:uploading:上传中',
|
||||
'upload-1:画布素材.png:folder-uploaded:uploading:上传中',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:画布素材.png:upload-1:-160:-107.5',
|
||||
@@ -496,7 +505,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
|
||||
deferredAsset.resolve({
|
||||
assetId: 'asset-persisted-canvas',
|
||||
folderId: 'project',
|
||||
folderId: 'folder-uploaded',
|
||||
label: '画布素材.png',
|
||||
imageSrc: 'data:image/png;base64,Y2FudmFz',
|
||||
width: 420,
|
||||
@@ -508,7 +517,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'asset-persisted-canvas:画布素材.png:project:ready:-',
|
||||
'asset-persisted-canvas:画布素材.png:folder-uploaded:ready:-',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:画布素材.png:asset-persisted-canvas:-160:-107.5',
|
||||
@@ -533,7 +542,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'persisted-原始尺寸素材.png:原始尺寸素材.png:project:ready:-:1536x1024',
|
||||
'persisted-原始尺寸素材.png:原始尺寸素材.png:folder-uploaded:ready:-:1536x1024',
|
||||
);
|
||||
expect(screen.getByTestId('layers').textContent).toContain(
|
||||
'layer-upload-1:原始尺寸素材.png:persisted-原始尺寸素材.png:-718:-462:1536x1024',
|
||||
@@ -558,7 +567,7 @@ describe('useImageCanvasUploadWorkflow', () => {
|
||||
await waitFor(() => {
|
||||
expect(openEditorLoginModal).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('assets').textContent).toContain(
|
||||
'upload-1:上传素材.png:project:failed:请先登录',
|
||||
'upload-1:上传素材.png:folder-uploaded:failed:请先登录',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorAsset,
|
||||
createEditorAssetFolder,
|
||||
createEditorProjectResource,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { uploadEditorSeedanceReferenceFile } from '../../services/image-editor/editorReferenceUploadClient';
|
||||
@@ -26,6 +27,11 @@ import type {
|
||||
QuickEditPanelState,
|
||||
UploadTarget,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
EDITOR_UPLOAD_ASSET_FOLDER_LABEL,
|
||||
resolvePreferredAssetUploadFolder,
|
||||
resolveUploadAssetFolder,
|
||||
} from './ImageCanvasAssetLibraryModel';
|
||||
import {
|
||||
SEEDANCE_REFERENCE_FILE_LIMITS,
|
||||
isImageFile,
|
||||
@@ -64,6 +70,7 @@ type UploadFilesOptions = {
|
||||
|
||||
type UploadRequestOptions = {
|
||||
addToCanvas?: boolean;
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
type UploadAssetFileOptions = UploadFilesOptions & {
|
||||
@@ -128,6 +135,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
const canAccessProtectedDataRef = useRef(canAccessProtectedData);
|
||||
const uploadTargetRef = useRef<UploadTarget>('asset');
|
||||
const uploadRequestOptionsRef = useRef<UploadRequestOptions>({});
|
||||
const uploadAssetFolderCreatePromiseRef = useRef<Promise<string> | null>(null);
|
||||
const [uploadTarget, setUploadTargetState] = useState<UploadTarget>('asset');
|
||||
|
||||
canAccessProtectedDataRef.current = canAccessProtectedData;
|
||||
@@ -814,11 +822,48 @@ export function useImageCanvasUploadWorkflow({
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadFolderId = resolveUploadFolderId({
|
||||
let uploadFolderId = resolveUploadFolderId({
|
||||
assetFolders,
|
||||
requestedFolderId: options.folderId,
|
||||
activeUploadFolderId,
|
||||
preferUploadFolder: !options.folderId,
|
||||
});
|
||||
if (!options.folderId && !resolveUploadAssetFolder(assetFolders)) {
|
||||
if (!uploadAssetFolderCreatePromiseRef.current) {
|
||||
uploadAssetFolderCreatePromiseRef.current = createEditorAssetFolder(
|
||||
EDITOR_UPLOAD_ASSET_FOLDER_LABEL,
|
||||
assetFolders.length + 100,
|
||||
)
|
||||
.then((folder) => {
|
||||
setAssetFolders((currentFolders) =>
|
||||
currentFolders.some(
|
||||
(currentFolder) => currentFolder.id === folder.folderId,
|
||||
)
|
||||
? currentFolders
|
||||
: [
|
||||
...currentFolders,
|
||||
{
|
||||
id: folder.folderId,
|
||||
label: folder.label,
|
||||
collapsed: folder.collapsed,
|
||||
systemDefault: folder.systemDefault,
|
||||
persisted: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
return folder.folderId;
|
||||
})
|
||||
.catch(() => {
|
||||
const fallbackFolder =
|
||||
resolvePreferredAssetUploadFolder(assetFolders);
|
||||
return fallbackFolder?.id ?? 'project';
|
||||
})
|
||||
.finally(() => {
|
||||
uploadAssetFolderCreatePromiseRef.current = null;
|
||||
});
|
||||
}
|
||||
uploadFolderId = await uploadAssetFolderCreatePromiseRef.current;
|
||||
}
|
||||
const uploadIndex = options.uploadIndex;
|
||||
let imageDimensions: { width: number; height: number } | null = null;
|
||||
if (mediaType === 'image') {
|
||||
@@ -1072,6 +1117,7 @@ export function useImageCanvasUploadWorkflow({
|
||||
} else {
|
||||
const requestOptions = uploadRequestOptionsRef.current;
|
||||
addUploadedFiles(files, {
|
||||
folderId: requestOptions.folderId,
|
||||
addToCanvas:
|
||||
requestOptions.addToCanvas ?? activeTool === 'upload',
|
||||
});
|
||||
|
||||
@@ -4255,6 +4255,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-search {
|
||||
border-bottom: 1px solid var(--image-canvas-brand-border-soft);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__sidebar-search-input {
|
||||
height: 2rem;
|
||||
border-radius: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.image-canvas-editor__asset-list {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user