拆分图片画布素材拖拽桥接
新增素材拖拽桥接 hook,承接素材拖向画布或文件夹的全局 pointer 监听 恢复认证弹窗 portal 渲染,避免全屏画布遮住账号入口 优化画布背景设置面板,补回当前色、色域、色相、预设、HEX 和恢复默认 补充素材拖拽、认证弹窗和背景面板回归测试并更新文档与 TRACKING
This commit is contained in:
@@ -137,3 +137,4 @@
|
||||
- 2026-06-17 前端拆分第十九阶段:新增 `useImageCanvasCanvasDropWorkflow`,把画布区域 drag over / drag leave / drop 分流从主视图抽出,覆盖素材库图片拖入画布、本地文件拖入画布、无关拖拽不拦截、默认文件夹选择和画布遮罩清理;主视图继续注入素材建层、文件上传、drop 点换算和素材移动高亮清理。新增 hook 单测覆盖拖拽入画布细节,主视图从 1452 行降至 1405 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 登录态刷新后素材 / 画布 / 小地图和底部工具栏可见,真实鼠标拖拽素材库图片到画布时出现 `添加到画布` 遮罩,松手后画布图层数量从 4 增至 5;`画布背景设置` 点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;抓手工具可切回选择工具,登录后控制台无前端 error。
|
||||
- 2026-06-17 前端拆分第二十阶段:新增 `ImageCanvasMetadataModalView`,把图片信息弹窗从主视图抽出,承载图片类型、生成输入、参考图、模型、分辨率、Provider、Task 和 Object 信息渲染;主视图只保留 `metadataLayer` 状态和关闭回调。同步修复未登录进入编辑器时项目 / 素材接口抢跑 401、`重置画布视图` 点击事件误传给适合视图函数的问题。新增组件单测覆盖生成图 metadata、上传图 fallback 和关闭回调,新增 hook / 主视图测试覆盖未登录不请求受保护素材 / 工程数据和重置按钮回归;主视图从 1405 行降至 1337 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后直接弹出 `账号入口`,且未登录状态下没有发起 `/api/editor/*` 请求;登录临时开发账号后 `重置画布视图` 无控制台错误,`画布背景设置` 保持 Lovart 式白色浮层,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)`,上传素材可加入画布,右上角图片信息按钮可打开不透明白底元数据弹窗,关闭后 `AI画布工具栏` 仍可见。
|
||||
- 2026-06-17 前端拆分第二十一阶段:新增 `useImageCanvasKeyboardShortcuts`,把 Ctrl / Cmd + Z 撤销、Ctrl / Cmd + Shift + Z 重做、Shift 状态、Backspace / Delete 删除、Escape 关闭临时面板和 Space 临时抓手从主视图抽出;主视图继续注入图层删除、生成对话框、快速编辑和 chrome 面板 setter。新增 hook 单测覆盖输入框忽略快捷键、删除选中图层、删除生成占位、Escape 保留生成中面板、Space 和 Shift;主视图从 1337 行降至 1250 行。验证命令:`npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录直接弹出 `账号入口` 且未抢跑 `/api/editor/*`,登录后 `/api/editor/assets/library` 和 `/api/editor/projects/recent` 为 200,`AI画布工具栏` 与 `画布面板入口` 可见,viewport 背景为 `rgb(248, 250, 252)` 且 `background-image: none`;按住 Space 从 `文字工具` 临时切到 `抓手工具`,松开恢复 `文字工具`;`画布背景设置` 点击 `暖灰` 后背景变为 `rgb(243, 240, 234)`;点击 `生成工具` 后 `Image Generator` 占位框、`生成图片` 对话框和 `AI画布工具栏` 同时可见,登录后控制台无前端 error。
|
||||
- 2026-06-17 前端拆分第二十二阶段:新增 `useImageCanvasAssetPointerDragBridge`,把素材卡片 pointer 拖拽到画布 / 文件夹的全局监听、拖拽激活、画布 drop 提示、文件夹高亮、移动素材、加入画布和点击抑制从主视图抽出;主视图继续负责素材库事实、画布建层、历史和工程资源持久化。同步恢复 `账号入口` 认证弹窗 portal 渲染,避免全屏画布遮住登录弹窗;`画布背景设置` 调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 未登录打开直接显示 `账号入口`;登录临时开发账号后 `画布背景设置` 显示当前色、色域、色相、自定义颜色、预设、HEX 和恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;上传图片进入 `项目素材`,真实 pointer 拖拽素材到画布后图层数从 0 到 1 且 `AI画布工具栏` 保持可见;新建 `SmokeFolder` 后真实 pointer 拖拽素材到文件夹,`项目素材` 变 0、`SmokeFolder` 变 1,素材执行移动而非拷贝。控制台仅有未登录 refresh 401,登录后编辑器 API 均为 200。
|
||||
|
||||
@@ -183,6 +183,14 @@
|
||||
- 主视图继续保留各工作流状态和具体副作用,例如图层删除、生成对话框、规格菜单、快速编辑面板和 chrome 面板状态;快捷键 hook 只接收 ref、setter 与回调,不直接读写素材库、路由或 API。
|
||||
- 该 hook 用独立单测覆盖输入框忽略快捷键、撤销重做、选中图层删除、生成占位删除、Escape 保留生成中面板、Space 临时抓手和 Shift 状态;主视图 DOM 测试继续覆盖真实编辑器里的 Backspace、Escape、Space 和 undo / redo 集成路径。
|
||||
|
||||
## 第二十二阶段模块
|
||||
|
||||
- `useImageCanvasAssetPointerDragBridge.ts`
|
||||
- 承载素材库卡片 pointer 拖拽桥接:全局 `pointermove` / `pointerup` / `pointercancel` 监听、拖拽激活阈值、画布 drop 提示、文件夹移动高亮、拖到文件夹移动素材、拖到画布创建图层,以及拖拽结束后的点击抑制。
|
||||
- 主视图继续保留素材库事实、画布建层、历史捕获、工程资源持久化和素材移动 API 编排;该 hook 只作为“侧栏素材拖拽到画布或文件夹”的事件桥,不直接读写路由、API 或图层持久化。
|
||||
- 该 hook 用独立单测覆盖拖拽激活、文件夹 drop、画布 drop、非激活拖拽清理和 pointer cancel 完成路径;主视图 DOM 测试继续覆盖真实素材库拖到画布和拖到文件夹的集成链路。
|
||||
- 本阶段同步恢复认证弹窗使用 portal 渲染,避免 `/editor/canvas` 这类全屏画布内容把 `账号入口` 遮住;同时把画布背景设置面板调整为独立白色浮层,包含当前色、色域、色相、自定义颜色、预设网格、HEX 输入和恢复默认。
|
||||
|
||||
## 后续阶段
|
||||
|
||||
- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。
|
||||
@@ -190,7 +198,7 @@
|
||||
|
||||
## 验证计划
|
||||
|
||||
- `npm run test -- src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`
|
||||
- `npm run test -- src/components/image-editor/useImageCanvasAssetPointerDragBridge.test.tsx src/components/image-editor/useImageCanvasKeyboardShortcuts.test.tsx src/components/image-editor/ImageCanvasMetadataModalView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/useImageCanvasCanvasDropWorkflow.test.tsx src/components/image-editor/useImageCanvasStageInteractions.test.tsx src/components/image-editor/useImageCanvasViewportControls.test.tsx src/components/image-editor/ImageCanvasInteractionModel.test.ts src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasEditorChrome.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/auth/PlatformAuthModalShell.test.tsx src/components/auth/AuthGate.test.tsx`
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
@@ -250,6 +250,16 @@ function AccountPanelProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenLoginProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
useEffect(() => {
|
||||
authUi?.openLoginModal();
|
||||
}, [authUi]);
|
||||
|
||||
return <div>编辑器内容</div>;
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -266,6 +276,22 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate portals page-level login requests above fullscreen content', async () => {
|
||||
const { container } = render(
|
||||
<AuthGate>
|
||||
<div className="image-canvas-editor">全屏画布</div>
|
||||
<AutoOpenLoginProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('编辑器内容')).toBeTruthy();
|
||||
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(dialog.parentElement?.className).toContain('z-[120]');
|
||||
});
|
||||
|
||||
test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => {
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
test('renders auth modal shell with platform theme and auth card chrome', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<PlatformAuthModalShell
|
||||
title="账号入口"
|
||||
platformTheme="light"
|
||||
@@ -22,6 +22,8 @@ test('renders auth modal shell with platform theme and auth card chrome', () =>
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.body.contains(dialog)).toBe(true);
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('platform-auth-card');
|
||||
|
||||
@@ -55,7 +55,6 @@ export function PlatformAuthModalShell({
|
||||
closeVariant="platformIcon"
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
portal={false}
|
||||
size={size}
|
||||
showHeader={showHeader}
|
||||
zIndexClassName={zIndexClassName}
|
||||
|
||||
@@ -2025,7 +2025,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
const settingsPanel = screen.getByRole('dialog', {
|
||||
name: '画布背景设置',
|
||||
});
|
||||
expect(within(settingsPanel).getByText('画布背景色')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
|
||||
expect(
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
within(settingsPanel).getByRole('button', { name: '暖灰' }),
|
||||
|
||||
@@ -51,6 +51,7 @@ import { useCanvasHistory } from './useCanvasHistory';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary';
|
||||
import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow';
|
||||
import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge';
|
||||
import { useImageCanvasCanvasDropWorkflow } from './useImageCanvasCanvasDropWorkflow';
|
||||
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
|
||||
import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow';
|
||||
@@ -124,13 +125,6 @@ export function ImageCanvasEditorView() {
|
||||
selectedLayerIdsRef.current = selectedLayerIds;
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
const assetsRef = useRef<EditorAsset[]>([]);
|
||||
const addAssetLayerRef = useRef<
|
||||
(asset: EditorAsset, screenCenter?: { x: number; y: number }) => void
|
||||
>(() => {});
|
||||
const moveAssetToFolderRef = useRef<
|
||||
(assetId: string, folderId: string) => void
|
||||
>(() => {});
|
||||
authUiRef.current = authUi;
|
||||
const openEditorLoginModal = useCallback(
|
||||
(postLoginAction?: (() => void) | null) => {
|
||||
@@ -268,10 +262,6 @@ export function ImageCanvasEditorView() {
|
||||
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
assetsRef.current = assets;
|
||||
}, [assets]);
|
||||
|
||||
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([]);
|
||||
@@ -723,74 +713,6 @@ export function ImageCanvasEditorView() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const updatePointerDrag = (event: PointerEvent) => {
|
||||
const currentDrag = assetPointerDragRef.current;
|
||||
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const distance = Math.hypot(
|
||||
event.clientX - currentDrag.startClientX,
|
||||
event.clientY - currentDrag.startClientY,
|
||||
);
|
||||
const dropFolderId = resolveAssetFolderId(event.clientX, event.clientY);
|
||||
const nextDrag: AssetPointerDragState = {
|
||||
...currentDrag,
|
||||
currentClientX: event.clientX,
|
||||
currentClientY: event.clientY,
|
||||
active: currentDrag.active || distance > 4,
|
||||
dropFolderId,
|
||||
};
|
||||
assetPointerDragRef.current = nextDrag;
|
||||
setAssetPointerDrag(nextDrag);
|
||||
setUploadDropTarget(
|
||||
resolveCanvasPoint(event.clientX, event.clientY) ? 'canvas' : null,
|
||||
);
|
||||
updateAssetMoveDropFolder(dropFolderId);
|
||||
};
|
||||
|
||||
const finishPointerDrag = (event: PointerEvent) => {
|
||||
const currentDrag = assetPointerDragRef.current;
|
||||
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const canvasPoint = resolveCanvasPoint(event.clientX, event.clientY);
|
||||
const dropFolderId =
|
||||
resolveAssetFolderId(event.clientX, event.clientY) ??
|
||||
currentDrag.dropFolderId;
|
||||
const draggedAsset = assetsRef.current.find(
|
||||
(asset) => asset.id === currentDrag.assetId,
|
||||
);
|
||||
assetPointerDragRef.current = null;
|
||||
setAssetPointerDrag(null);
|
||||
setUploadDropTarget(null);
|
||||
updateAssetMoveDropFolder(null);
|
||||
if (!currentDrag.active || !draggedAsset) {
|
||||
return;
|
||||
}
|
||||
suppressAssetClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressAssetClickRef.current = false;
|
||||
}, 0);
|
||||
if (dropFolderId && dropFolderId !== draggedAsset.folderId) {
|
||||
moveAssetToFolderRef.current(draggedAsset.id, dropFolderId);
|
||||
return;
|
||||
}
|
||||
if (canvasPoint) {
|
||||
addAssetLayerRef.current(draggedAsset, canvasPoint);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', updatePointerDrag);
|
||||
window.addEventListener('pointerup', finishPointerDrag);
|
||||
window.addEventListener('pointercancel', finishPointerDrag);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', updatePointerDrag);
|
||||
window.removeEventListener('pointerup', finishPointerDrag);
|
||||
window.removeEventListener('pointercancel', finishPointerDrag);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addAssetLayer = (
|
||||
asset: EditorAsset,
|
||||
position?: { x: number; y: number },
|
||||
@@ -811,9 +733,19 @@ export function ImageCanvasEditorView() {
|
||||
selectSingleLayer(nextLayer.id);
|
||||
setHoveredLayerId(null);
|
||||
};
|
||||
addAssetLayerRef.current = addAssetLayer;
|
||||
|
||||
moveAssetToFolderRef.current = moveAssetToFolder;
|
||||
useImageCanvasAssetPointerDragBridge({
|
||||
assetPointerDragRef,
|
||||
suppressAssetClickRef,
|
||||
assets,
|
||||
resolveAssetFolderId,
|
||||
resolveCanvasPoint,
|
||||
setAssetPointerDrag,
|
||||
setUploadDropTarget,
|
||||
updateAssetMoveDropFolder,
|
||||
moveAssetToFolder,
|
||||
addAssetLayer,
|
||||
});
|
||||
|
||||
deleteLayerByIdRef.current = deleteLayerById;
|
||||
|
||||
|
||||
@@ -881,7 +881,7 @@ export function ImageCanvasStageView({
|
||||
aria-label="画布背景设置"
|
||||
>
|
||||
<div className="image-canvas-editor__background-panel-head">
|
||||
<span>画布背景色</span>
|
||||
<span>画布背景</span>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-close"
|
||||
@@ -891,6 +891,14 @@ export function ImageCanvasStageView({
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-canvas-editor__background-current-row">
|
||||
<span
|
||||
className="image-canvas-editor__background-current-preview"
|
||||
style={{ backgroundColor: canvasBackgroundColor }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{canvasBackgroundColor}</span>
|
||||
</div>
|
||||
<label className="image-canvas-editor__background-spectrum">
|
||||
<input
|
||||
type="color"
|
||||
@@ -939,27 +947,29 @@ export function ImageCanvasStageView({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="image-canvas-editor__background-hex-field">
|
||||
<span>HEX</span>
|
||||
<input
|
||||
aria-label="画布背景十六进制颜色"
|
||||
value={canvasBackgroundHexValue}
|
||||
spellCheck={false}
|
||||
onChange={(event) =>
|
||||
onCanvasBackgroundHexChange(event.currentTarget.value)
|
||||
<div className="image-canvas-editor__background-footer">
|
||||
<label className="image-canvas-editor__background-hex-field">
|
||||
<span>HEX</span>
|
||||
<input
|
||||
aria-label="画布背景十六进制颜色"
|
||||
value={canvasBackgroundHexValue}
|
||||
spellCheck={false}
|
||||
onChange={(event) =>
|
||||
onCanvasBackgroundHexChange(event.currentTarget.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-reset"
|
||||
onClick={() =>
|
||||
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-reset"
|
||||
onClick={() =>
|
||||
onApplyCanvasBackgroundColor(DEFAULT_CANVAS_BACKGROUND_COLOR)
|
||||
}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
恢复默认
|
||||
</button>
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
恢复默认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { useImageCanvasAssetPointerDragBridge } from './useImageCanvasAssetPointerDragBridge';
|
||||
|
||||
const defaultAssets: EditorAsset[] = [
|
||||
{
|
||||
id: 'asset-1',
|
||||
label: '素材一',
|
||||
src: 'data:image/png;base64,asset-1',
|
||||
width: 320,
|
||||
height: 240,
|
||||
folderId: 'project',
|
||||
sourceKind: 'uploaded',
|
||||
sourceType: 'uploaded',
|
||||
persisted: true,
|
||||
},
|
||||
];
|
||||
|
||||
function dispatchPointerEvent(
|
||||
type: string,
|
||||
init: MouseEventInit & { pointerId: number },
|
||||
) {
|
||||
const event = new MouseEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...init,
|
||||
});
|
||||
Object.defineProperty(event, 'pointerId', { value: init.pointerId });
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
function AssetPointerDragBridgeHarness({
|
||||
initialDrag,
|
||||
assets = defaultAssets,
|
||||
resolveAssetFolderId = vi.fn(() => null),
|
||||
resolveCanvasPoint = vi.fn(() => null),
|
||||
moveAssetToFolder = vi.fn(),
|
||||
addAssetLayer = vi.fn(),
|
||||
}: {
|
||||
initialDrag?: AssetPointerDragState | null;
|
||||
assets?: EditorAsset[];
|
||||
resolveAssetFolderId?: (clientX: number, clientY: number) => string | null;
|
||||
resolveCanvasPoint?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => { x: number; y: number } | null;
|
||||
moveAssetToFolder?: (assetId: string, folderId: string) => void;
|
||||
addAssetLayer?: (asset: EditorAsset, position?: { x: number; y: number }) => void;
|
||||
}) {
|
||||
const assetPointerDragRef = useRef<AssetPointerDragState | null>(
|
||||
initialDrag ?? {
|
||||
assetId: 'asset-1',
|
||||
pointerId: 7,
|
||||
startClientX: 10,
|
||||
startClientY: 10,
|
||||
currentClientX: 10,
|
||||
currentClientY: 10,
|
||||
active: false,
|
||||
dropFolderId: null,
|
||||
},
|
||||
);
|
||||
const suppressAssetClickRef = useRef(false);
|
||||
const [assetPointerDrag, setAssetPointerDrag] =
|
||||
useState<AssetPointerDragState | null>(assetPointerDragRef.current);
|
||||
const [uploadDropTarget, setUploadDropTarget] = useState<
|
||||
'canvas' | 'assets' | null
|
||||
>(null);
|
||||
const [dropFolderId, updateAssetMoveDropFolder] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useImageCanvasAssetPointerDragBridge({
|
||||
assetPointerDragRef,
|
||||
suppressAssetClickRef,
|
||||
assets,
|
||||
resolveAssetFolderId,
|
||||
resolveCanvasPoint,
|
||||
setAssetPointerDrag,
|
||||
setUploadDropTarget,
|
||||
updateAssetMoveDropFolder,
|
||||
moveAssetToFolder,
|
||||
addAssetLayer,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="drag-active">
|
||||
{assetPointerDrag ? String(assetPointerDrag.active) : 'none'}
|
||||
</span>
|
||||
<span data-testid="drag-x">
|
||||
{assetPointerDrag ? assetPointerDrag.currentClientX : 'none'}
|
||||
</span>
|
||||
<span data-testid="drop-target">{uploadDropTarget ?? '-'}</span>
|
||||
<span data-testid="drop-folder">{dropFolderId ?? '-'}</span>
|
||||
<span data-testid="suppressed">{String(suppressAssetClickRef.current)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('useImageCanvasAssetPointerDragBridge', () => {
|
||||
it('activates sidebar asset drags and updates canvas and folder drop hints', () => {
|
||||
const resolveAssetFolderId = vi.fn(() => 'folder-1');
|
||||
const resolveCanvasPoint = vi.fn(() => ({ x: 100, y: 80 }));
|
||||
render(
|
||||
<AssetPointerDragBridgeHarness
|
||||
resolveAssetFolderId={resolveAssetFolderId}
|
||||
resolveCanvasPoint={resolveCanvasPoint}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent('pointermove', {
|
||||
pointerId: 7,
|
||||
clientX: 18,
|
||||
clientY: 18,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('drag-active').textContent).toBe('true');
|
||||
expect(screen.getByTestId('drag-x').textContent).toBe('18');
|
||||
expect(screen.getByTestId('drop-target').textContent).toBe('canvas');
|
||||
expect(screen.getByTestId('drop-folder').textContent).toBe('folder-1');
|
||||
});
|
||||
|
||||
it('moves an active asset drag into a different folder on pointer up', () => {
|
||||
const moveAssetToFolder = vi.fn();
|
||||
render(
|
||||
<AssetPointerDragBridgeHarness
|
||||
initialDrag={{
|
||||
assetId: 'asset-1',
|
||||
pointerId: 7,
|
||||
startClientX: 10,
|
||||
startClientY: 10,
|
||||
currentClientX: 30,
|
||||
currentClientY: 30,
|
||||
active: true,
|
||||
dropFolderId: 'folder-1',
|
||||
}}
|
||||
resolveAssetFolderId={() => 'folder-1'}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent('pointerup', {
|
||||
pointerId: 7,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
});
|
||||
|
||||
expect(moveAssetToFolder).toHaveBeenCalledWith('asset-1', 'folder-1');
|
||||
expect(screen.getByTestId('drag-active').textContent).toBe('none');
|
||||
expect(screen.getByTestId('drop-target').textContent).toBe('-');
|
||||
expect(screen.getByTestId('drop-folder').textContent).toBe('-');
|
||||
expect(screen.getByTestId('suppressed').textContent).toBe('true');
|
||||
});
|
||||
|
||||
it('adds an active dragged asset to the canvas when released over the canvas', () => {
|
||||
const addAssetLayer = vi.fn();
|
||||
render(
|
||||
<AssetPointerDragBridgeHarness
|
||||
initialDrag={{
|
||||
assetId: 'asset-1',
|
||||
pointerId: 7,
|
||||
startClientX: 10,
|
||||
startClientY: 10,
|
||||
currentClientX: 48,
|
||||
currentClientY: 64,
|
||||
active: true,
|
||||
dropFolderId: null,
|
||||
}}
|
||||
resolveCanvasPoint={() => ({ x: 480, y: 640 })}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent('pointerup', {
|
||||
pointerId: 7,
|
||||
clientX: 48,
|
||||
clientY: 64,
|
||||
});
|
||||
});
|
||||
|
||||
expect(addAssetLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'asset-1' }),
|
||||
{ x: 480, y: 640 },
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans up inactive drags without moving or adding assets', () => {
|
||||
const moveAssetToFolder = vi.fn();
|
||||
const addAssetLayer = vi.fn();
|
||||
render(
|
||||
<AssetPointerDragBridgeHarness
|
||||
initialDrag={{
|
||||
assetId: 'asset-1',
|
||||
pointerId: 7,
|
||||
startClientX: 10,
|
||||
startClientY: 10,
|
||||
currentClientX: 10,
|
||||
currentClientY: 10,
|
||||
active: false,
|
||||
dropFolderId: 'folder-1',
|
||||
}}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent('pointerup', {
|
||||
pointerId: 7,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(moveAssetToFolder).not.toHaveBeenCalled();
|
||||
expect(addAssetLayer).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('drag-active').textContent).toBe('none');
|
||||
});
|
||||
|
||||
it('uses the same finish path for pointer cancellation', () => {
|
||||
const moveAssetToFolder = vi.fn();
|
||||
const addAssetLayer = vi.fn();
|
||||
render(
|
||||
<AssetPointerDragBridgeHarness
|
||||
initialDrag={{
|
||||
assetId: 'asset-1',
|
||||
pointerId: 8,
|
||||
startClientX: 10,
|
||||
startClientY: 10,
|
||||
currentClientX: 24,
|
||||
currentClientY: 24,
|
||||
active: true,
|
||||
dropFolderId: 'folder-1',
|
||||
}}
|
||||
moveAssetToFolder={moveAssetToFolder}
|
||||
addAssetLayer={addAssetLayer}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent('pointercancel', {
|
||||
pointerId: 8,
|
||||
clientX: 24,
|
||||
clientY: 24,
|
||||
});
|
||||
});
|
||||
|
||||
expect(moveAssetToFolder).toHaveBeenCalledWith('asset-1', 'folder-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { type RefObject, useEffect, useRef } from 'react';
|
||||
|
||||
import type {
|
||||
AssetPointerDragState,
|
||||
EditorAsset,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasPoint = { x: number; y: number };
|
||||
|
||||
type UseImageCanvasAssetPointerDragBridgeOptions = {
|
||||
assetPointerDragRef: RefObject<AssetPointerDragState | null>;
|
||||
suppressAssetClickRef: RefObject<boolean>;
|
||||
assets: EditorAsset[];
|
||||
resolveAssetFolderId: (clientX: number, clientY: number) => string | null;
|
||||
resolveCanvasPoint: (clientX: number, clientY: number) => CanvasPoint | null;
|
||||
setAssetPointerDrag: (dragState: AssetPointerDragState | null) => void;
|
||||
setUploadDropTarget: (target: 'canvas' | 'assets' | null) => void;
|
||||
updateAssetMoveDropFolder: (folderId: string | null) => void;
|
||||
moveAssetToFolder: (assetId: string, folderId: string) => void;
|
||||
addAssetLayer: (asset: EditorAsset, position?: CanvasPoint) => void;
|
||||
};
|
||||
|
||||
export function useImageCanvasAssetPointerDragBridge({
|
||||
assetPointerDragRef,
|
||||
suppressAssetClickRef,
|
||||
assets,
|
||||
resolveAssetFolderId,
|
||||
resolveCanvasPoint,
|
||||
setAssetPointerDrag,
|
||||
setUploadDropTarget,
|
||||
updateAssetMoveDropFolder,
|
||||
moveAssetToFolder,
|
||||
addAssetLayer,
|
||||
}: UseImageCanvasAssetPointerDragBridgeOptions) {
|
||||
const assetsRef = useRef(assets);
|
||||
const callbacksRef = useRef({
|
||||
resolveAssetFolderId,
|
||||
resolveCanvasPoint,
|
||||
setAssetPointerDrag,
|
||||
setUploadDropTarget,
|
||||
updateAssetMoveDropFolder,
|
||||
moveAssetToFolder,
|
||||
addAssetLayer,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
assetsRef.current = assets;
|
||||
}, [assets]);
|
||||
|
||||
callbacksRef.current = {
|
||||
resolveAssetFolderId,
|
||||
resolveCanvasPoint,
|
||||
setAssetPointerDrag,
|
||||
setUploadDropTarget,
|
||||
updateAssetMoveDropFolder,
|
||||
moveAssetToFolder,
|
||||
addAssetLayer,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updatePointerDrag = (event: PointerEvent) => {
|
||||
const currentDrag = assetPointerDragRef.current;
|
||||
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
resolveAssetFolderId: resolveFolder,
|
||||
resolveCanvasPoint: resolvePoint,
|
||||
setAssetPointerDrag: setPointerDrag,
|
||||
setUploadDropTarget: setDropTarget,
|
||||
updateAssetMoveDropFolder: updateDropFolder,
|
||||
} = callbacksRef.current;
|
||||
const distance = Math.hypot(
|
||||
event.clientX - currentDrag.startClientX,
|
||||
event.clientY - currentDrag.startClientY,
|
||||
);
|
||||
const dropFolderId = resolveFolder(event.clientX, event.clientY);
|
||||
const nextDrag: AssetPointerDragState = {
|
||||
...currentDrag,
|
||||
currentClientX: event.clientX,
|
||||
currentClientY: event.clientY,
|
||||
active: currentDrag.active || distance > 4,
|
||||
dropFolderId,
|
||||
};
|
||||
assetPointerDragRef.current = nextDrag;
|
||||
setPointerDrag(nextDrag);
|
||||
setDropTarget(resolvePoint(event.clientX, event.clientY) ? 'canvas' : null);
|
||||
updateDropFolder(dropFolderId);
|
||||
};
|
||||
|
||||
const finishPointerDrag = (event: PointerEvent) => {
|
||||
const currentDrag = assetPointerDragRef.current;
|
||||
if (!currentDrag || currentDrag.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
const {
|
||||
resolveAssetFolderId: resolveFolder,
|
||||
resolveCanvasPoint: resolvePoint,
|
||||
setAssetPointerDrag: setPointerDrag,
|
||||
setUploadDropTarget: setDropTarget,
|
||||
updateAssetMoveDropFolder: updateDropFolder,
|
||||
moveAssetToFolder: moveToFolder,
|
||||
addAssetLayer: addLayer,
|
||||
} = callbacksRef.current;
|
||||
const canvasPoint = resolvePoint(event.clientX, event.clientY);
|
||||
const dropFolderId =
|
||||
resolveFolder(event.clientX, event.clientY) ?? currentDrag.dropFolderId;
|
||||
const draggedAsset = assetsRef.current.find(
|
||||
(asset) => asset.id === currentDrag.assetId,
|
||||
);
|
||||
assetPointerDragRef.current = null;
|
||||
setPointerDrag(null);
|
||||
setDropTarget(null);
|
||||
updateDropFolder(null);
|
||||
if (!currentDrag.active || !draggedAsset) {
|
||||
return;
|
||||
}
|
||||
suppressAssetClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressAssetClickRef.current = false;
|
||||
}, 0);
|
||||
if (dropFolderId && dropFolderId !== draggedAsset.folderId) {
|
||||
moveToFolder(draggedAsset.id, dropFolderId);
|
||||
return;
|
||||
}
|
||||
if (canvasPoint) {
|
||||
addLayer(draggedAsset, canvasPoint);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', updatePointerDrag);
|
||||
window.addEventListener('pointerup', finishPointerDrag);
|
||||
window.addEventListener('pointercancel', finishPointerDrag);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', updatePointerDrag);
|
||||
window.removeEventListener('pointerup', finishPointerDrag);
|
||||
window.removeEventListener('pointercancel', finishPointerDrag);
|
||||
};
|
||||
}, [assetPointerDragRef, suppressAssetClickRef]);
|
||||
}
|
||||
@@ -4469,12 +4469,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
bottom: calc(100% + 0.55rem);
|
||||
z-index: 24;
|
||||
display: grid;
|
||||
width: min(16rem, calc(100vw - 1.5rem));
|
||||
gap: 0.5rem;
|
||||
width: min(17.5rem, calc(100vw - 1.5rem));
|
||||
gap: 0.62rem;
|
||||
border: 1px solid rgba(203, 213, 225, 0.72);
|
||||
border-radius: 0.86rem;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
padding: 0.72rem;
|
||||
border-radius: 0.92rem;
|
||||
background: #ffffff;
|
||||
padding: 0.75rem;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.15);
|
||||
}
|
||||
@@ -4489,6 +4489,29 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-weight: 880;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-current-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.6rem;
|
||||
background: #f8fafc;
|
||||
padding: 0.35rem 0.45rem;
|
||||
color: #475569;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 820;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-current-preview {
|
||||
width: 1.24rem;
|
||||
height: 1.24rem;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 0.36rem;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-close {
|
||||
display: inline-flex;
|
||||
width: 1.65rem;
|
||||
@@ -4514,9 +4537,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
.image-canvas-editor__background-spectrum {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 8.2rem;
|
||||
height: 7.6rem;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.58rem;
|
||||
background:
|
||||
linear-gradient(to top, #000000, transparent),
|
||||
linear-gradient(to right, #ffffff, transparent),
|
||||
@@ -4575,38 +4599,32 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-presets {
|
||||
display: flex;
|
||||
gap: 0.62rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.08rem 0.05rem 0.18rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-presets::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
padding: 0.02rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preset {
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
height: 2.15rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.58rem;
|
||||
background: #f8fafc;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-preset {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 100%;
|
||||
height: 2.15rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preset[aria-pressed='true'] {
|
||||
border-color: #38bdf8;
|
||||
background: #f8fafc;
|
||||
background: #e0f2fe;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-preset .image-canvas-editor__background-swatch {
|
||||
@@ -4616,6 +4634,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-footer {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-hex-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -4646,7 +4671,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.image-canvas-editor__background-reset {
|
||||
display: inline-flex;
|
||||
justify-self: start;
|
||||
height: 1.9rem;
|
||||
white-space: nowrap;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
border: 1px solid #d7dfe9;
|
||||
|
||||
Reference in New Issue
Block a user