Merge branch 'master' into editor-agent-refactored
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import { removeBackgroundFromRgba } from '../../../packages/shared/src/assets/chromaKey';
|
||||
import {
|
||||
AnimationState,
|
||||
type Character,
|
||||
@@ -453,19 +452,6 @@ export function loadImageFromSource(source: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function loadVideoFromSource(source: string) {
|
||||
return new Promise<HTMLVideoElement>((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.preload = 'auto';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.onloadeddata = () => resolve(video);
|
||||
video.onerror = () => reject(new Error(`加载视频失败:${source}`));
|
||||
video.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
function createCanvas(width: number, height: number) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
@@ -708,200 +694,6 @@ export async function buildAnimationClipFromMaster(
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
function applyGreenScreenAlpha(
|
||||
context: CanvasRenderingContext2D,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const imageData = context.getImageData(0, 0, width, height);
|
||||
removeBackgroundFromRgba(imageData.data, width, height);
|
||||
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
async function normalizeFrameSourceToDataUrl(
|
||||
frameSource: string,
|
||||
options: {
|
||||
frameWidth: number;
|
||||
frameHeight: number;
|
||||
applyChromaKey: boolean;
|
||||
},
|
||||
) {
|
||||
const image = await loadImageFromSource(frameSource);
|
||||
const { canvas, context } = createCanvas(
|
||||
options.frameWidth,
|
||||
options.frameHeight,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export async function normalizeMasterVisualSourceToDataUrl(
|
||||
source: string,
|
||||
options: {
|
||||
applyChromaKey?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const image = await loadImageFromSource(source);
|
||||
const { canvas, context } = createCanvas(
|
||||
MASTER_VISUAL_WIDTH,
|
||||
MASTER_VISUAL_HEIGHT,
|
||||
);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedImage(context, image, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey !== false) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return {
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function seekVideo(video: HTMLVideoElement, targetTime: number) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (Math.abs(video.currentTime - targetTime) < 0.001) {
|
||||
window.requestAnimationFrame(() => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSeeked = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const handleError = () => {
|
||||
cleanup();
|
||||
reject(new Error('视频定位失败'));
|
||||
};
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('seeked', handleSeeked);
|
||||
video.removeEventListener('error', handleError);
|
||||
};
|
||||
|
||||
video.addEventListener('seeked', handleSeeked, { once: true });
|
||||
video.addEventListener('error', handleError, { once: true });
|
||||
video.currentTime = Math.max(0, targetTime);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromImageSources(
|
||||
sources: string[],
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
},
|
||||
) {
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const frames = await Promise.all(
|
||||
sources.map((source) =>
|
||||
normalizeFrameSourceToDataUrl(source, {
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
applyChromaKey: options.applyChromaKey ?? false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
export async function buildAnimationClipFromVideoSource(
|
||||
videoSource: string,
|
||||
options: {
|
||||
animation: AnimationState;
|
||||
fps: number;
|
||||
loop: boolean;
|
||||
frameCount?: number;
|
||||
frameWidth?: number;
|
||||
frameHeight?: number;
|
||||
applyChromaKey?: boolean;
|
||||
sampleStartRatio?: number;
|
||||
sampleEndRatio?: number;
|
||||
},
|
||||
) {
|
||||
const video = await loadVideoFromSource(videoSource);
|
||||
const frameWidth = options.frameWidth ?? GENERATED_FRAME_WIDTH;
|
||||
const frameHeight = options.frameHeight ?? GENERATED_FRAME_HEIGHT;
|
||||
const duration =
|
||||
Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 1;
|
||||
const derivedFrameCount = Math.max(
|
||||
2,
|
||||
options.frameCount ?? Math.round(duration * Math.max(1, options.fps)),
|
||||
);
|
||||
const sampleStartRatio = Math.min(
|
||||
0.85,
|
||||
Math.max(0, options.sampleStartRatio ?? 0),
|
||||
);
|
||||
const sampleEndRatio = Math.min(
|
||||
1,
|
||||
Math.max(sampleStartRatio + 0.05, options.sampleEndRatio ?? 1),
|
||||
);
|
||||
const sampleWindowDuration = duration * (sampleEndRatio - sampleStartRatio);
|
||||
const { canvas, context } = createCanvas(frameWidth, frameHeight);
|
||||
const frames: string[] = [];
|
||||
|
||||
for (let frameIndex = 0; frameIndex < derivedFrameCount; frameIndex += 1) {
|
||||
const progress = options.loop
|
||||
? frameIndex / derivedFrameCount
|
||||
: frameIndex / Math.max(1, derivedFrameCount - 1);
|
||||
const targetTime = Math.min(
|
||||
duration - 0.001,
|
||||
duration * sampleStartRatio + sampleWindowDuration * progress,
|
||||
);
|
||||
|
||||
await seekVideo(video, targetTime);
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
drawContainedSource(context, video, video.videoWidth, video.videoHeight, {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
});
|
||||
|
||||
if (options.applyChromaKey) {
|
||||
applyGreenScreenAlpha(context, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
frames.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
|
||||
return {
|
||||
animation: options.animation,
|
||||
frames,
|
||||
fps: Math.max(1, options.fps),
|
||||
loop: options.loop,
|
||||
frameWidth,
|
||||
frameHeight,
|
||||
previewVideoPath: videoSource,
|
||||
} satisfies DraftAnimationClip;
|
||||
}
|
||||
|
||||
async function buildReferenceVideoFromFrameSources(
|
||||
frameSources: string[],
|
||||
options: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canvasDisplayViewportToViewport,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
createLayerFromAsset,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
@@ -49,6 +50,42 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes canvas background settings without treating them as layers', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: ' #ABC ',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(layout);
|
||||
|
||||
expect(layerItems).toEqual([]);
|
||||
expect(generationDialogs).toEqual([]);
|
||||
expect(canvasBackgroundColor).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('drops invalid canvas background settings from serialized layouts', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: '#not-a-color',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([]);
|
||||
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
|
||||
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
it('keeps only one default asset folder when normalizing the persisted library', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
|
||||
@@ -300,8 +300,15 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
|
||||
dialog: CanvasGenerationDialogState;
|
||||
};
|
||||
|
||||
type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & {
|
||||
itemType: 'canvas-settings';
|
||||
canvasBackgroundColor?: string;
|
||||
};
|
||||
|
||||
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
||||
|
||||
const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default';
|
||||
|
||||
function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
||||
const normalizedResourceId = resourceId?.trim();
|
||||
return Boolean(
|
||||
@@ -419,14 +426,37 @@ export function serializeCanvasGenerationDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function serializeCanvasSettings({
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasSettingsLayoutSnapshot | null {
|
||||
const normalizedBackgroundColor = canvasBackgroundColor
|
||||
? normalizeCanvasBackgroundHex(canvasBackgroundColor)
|
||||
: null;
|
||||
if (!normalizedBackgroundColor) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
itemType: 'canvas-settings',
|
||||
layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
canvasBackgroundColor: normalizedBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasLayoutItems {
|
||||
const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor });
|
||||
return [
|
||||
...(canvasSettings ? [canvasSettings] : []),
|
||||
...layers.map(serializeLayer),
|
||||
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
|
||||
];
|
||||
@@ -445,6 +475,12 @@ export function isCanvasGenerationDialogLayoutItem(
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasSettingsLayoutItem(
|
||||
item: EditorProjectLayerSnapshot,
|
||||
): item is CanvasSettingsLayoutSnapshot {
|
||||
return item.itemType === 'canvas-settings';
|
||||
}
|
||||
|
||||
export function splitCanvasLayoutItems(
|
||||
items: EditorProjectLayerSnapshot[],
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
@@ -452,11 +488,23 @@ export function splitCanvasLayoutItems(
|
||||
): {
|
||||
layerItems: EditorProjectLayerSnapshot[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string;
|
||||
} {
|
||||
const layerItems: EditorProjectLayerSnapshot[] = [];
|
||||
const generationDialogs: CanvasGenerationDialogState[] = [];
|
||||
let canvasBackgroundColor: string | undefined;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isCanvasSettingsLayoutItem(item)) {
|
||||
const normalizedBackgroundColor =
|
||||
typeof item.canvasBackgroundColor === 'string'
|
||||
? normalizeCanvasBackgroundHex(item.canvasBackgroundColor)
|
||||
: null;
|
||||
if (normalizedBackgroundColor) {
|
||||
canvasBackgroundColor = normalizedBackgroundColor;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCanvasGenerationDialogLayoutItem(item)) {
|
||||
const dialog = hydrateCanvasGenerationDialog(
|
||||
item.dialog,
|
||||
@@ -471,7 +519,7 @@ export function splitCanvasLayoutItems(
|
||||
layerItems.push(item);
|
||||
});
|
||||
|
||||
return { layerItems, generationDialogs };
|
||||
return { layerItems, generationDialogs, canvasBackgroundColor };
|
||||
}
|
||||
|
||||
export function hydrateCanvasGenerationDialog(
|
||||
|
||||
@@ -359,6 +359,46 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the canvas background color from the persisted project layout', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-background',
|
||||
title: '背景色项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#112233',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
await waitFor(() => {
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(17, 34, 51)',
|
||||
);
|
||||
});
|
||||
|
||||
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
fireEvent.click(
|
||||
within(panelToolbar).getByRole('button', { name: '画布背景色' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
within(
|
||||
screen.getByRole('dialog', { name: '画布背景设置' }),
|
||||
).getByLabelText('画布背景十六进制颜色') as HTMLInputElement
|
||||
).value,
|
||||
).toBe('#112233');
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-guide',
|
||||
@@ -545,7 +585,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
|
||||
it('opens the account recharge entry from the canvas topbar mud point button', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -572,8 +612,162 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '账户充值' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
|
||||
});
|
||||
|
||||
it('suspends canvas interaction during account payment dialogs and restores completed quick edit selections', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
user: {
|
||||
id: 'user-1',
|
||||
publicUserCode: 'U001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: '138****0000',
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
canAccessProtectedData: true,
|
||||
})}
|
||||
>
|
||||
<ImageCanvasEditorView />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
const sourceLayer = (
|
||||
await screen.findByAltText('画布图片:拼图素材')
|
||||
).closest('button')!;
|
||||
dispatchPointerEvent(sourceLayer, 'pointerdown', {
|
||||
button: 0,
|
||||
pointerId: 71,
|
||||
clientX: 120,
|
||||
clientY: 120,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||||
|
||||
const quickEditDialog = screen.getByRole('dialog', {
|
||||
name: '快速编辑图片',
|
||||
});
|
||||
const selectionToolbar = screen.getByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
const rectTool = within(selectionToolbar).getByRole('button', {
|
||||
name: '矩形框选',
|
||||
});
|
||||
fireEvent.click(rectTool);
|
||||
|
||||
const selectionCanvas = screen.getByRole('application', {
|
||||
name: 'UI素材框选画布',
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
|
||||
pointerId: 72,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointermove', {
|
||||
pointerId: 72,
|
||||
clientX: 90,
|
||||
clientY: 90,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerup', {
|
||||
pointerId: 72,
|
||||
clientX: 90,
|
||||
clientY: 90,
|
||||
});
|
||||
|
||||
expect(
|
||||
(
|
||||
within(quickEditDialog).getByLabelText(
|
||||
'快速编辑提示词',
|
||||
) as HTMLTextAreaElement
|
||||
).value,
|
||||
).toContain('对1号红色圈选框里的内容做以下修改');
|
||||
|
||||
dispatchPointerEvent(selectionCanvas, 'pointerdown', {
|
||||
pointerId: 73,
|
||||
clientX: 30,
|
||||
clientY: 30,
|
||||
});
|
||||
dispatchPointerEvent(selectionCanvas, 'pointermove', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
fireEvent.keyDown(window, {
|
||||
key: 'c',
|
||||
code: 'KeyC',
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', {
|
||||
name: '泥点余额 1,234泥点',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '账户充值' }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||||
fireEvent.contextMenu(viewport, { clientX: 320, clientY: 220 });
|
||||
const pasteEvent = new Event('paste', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(pasteEvent);
|
||||
});
|
||||
|
||||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||||
expect(screen.getAllByAltText('画布图片:拼图素材')).toHaveLength(1);
|
||||
expect(screen.queryByRole('menu', { name: '画布右键菜单' })).toBeNull();
|
||||
const pausedToolbar = screen.getByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
expect(
|
||||
within(pausedToolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(viewport.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭账户充值' }));
|
||||
|
||||
const resumedToolbar = await screen.findByRole('toolbar', {
|
||||
name: '快速编辑框选工具',
|
||||
});
|
||||
expect(viewport.getAttribute('aria-disabled')).toBeNull();
|
||||
expect(
|
||||
within(resumedToolbar)
|
||||
.getByRole('button', { name: '矩形框选' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
const resumedCanvas = screen.getByRole('application', {
|
||||
name: 'UI素材框选画布',
|
||||
});
|
||||
dispatchPointerEvent(resumedCanvas, 'pointermove', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
dispatchPointerEvent(resumedCanvas, 'pointerup', {
|
||||
pointerId: 73,
|
||||
clientX: 110,
|
||||
clientY: 110,
|
||||
});
|
||||
|
||||
const resumedPrompt = screen.getByLabelText(
|
||||
'快速编辑提示词',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(resumedPrompt.value).toContain('对1号红色圈选框里的内容做以下修改');
|
||||
expect(resumedPrompt.value).not.toContain('2号红色圈选框');
|
||||
});
|
||||
|
||||
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
||||
@@ -1767,9 +1961,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色盘')).toBeTruthy();
|
||||
expect(
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
|
||||
).toBeTruthy();
|
||||
expect(settingsPanel.querySelector('input[type="color"]')).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(settingsPanel).getByRole('button', { name: '暖灰' }),
|
||||
@@ -1779,13 +1975,6 @@ describe('ImageCanvasEditorView', () => {
|
||||
'rgb(243, 240, 234)',
|
||||
);
|
||||
|
||||
fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
|
||||
target: { value: '#ffffff' },
|
||||
});
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(255, 255, 255)',
|
||||
);
|
||||
|
||||
const hexInput =
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色');
|
||||
fireEvent.change(hexInput, { target: { value: '#abc' } });
|
||||
|
||||
@@ -33,6 +33,7 @@ import { usePlatformProfileCenterController } from '../platform-entry/usePlatfor
|
||||
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
|
||||
import {
|
||||
canvasAssetKindOrNull,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
generationInputsOrNull,
|
||||
isInlineEditorMediaSource,
|
||||
resolveContextMenuPosition,
|
||||
@@ -307,6 +308,7 @@ export function ImageCanvasEditorView({
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
@@ -485,6 +487,7 @@ export function ImageCanvasEditorView({
|
||||
const {
|
||||
activeRechargeTab,
|
||||
buyRechargeProduct,
|
||||
closeNativeWechatPayment,
|
||||
confirmNativeWechatPayment,
|
||||
isLoadingRechargeCenter,
|
||||
isRechargeOpen,
|
||||
@@ -515,6 +518,12 @@ export function ImageCanvasEditorView({
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
Boolean(nativeWechatPayment) ||
|
||||
Boolean(rechargePaymentResult) ||
|
||||
Boolean(wechatRechargeOrderConfirmationState);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authUi || authUi.user || authUi.canAccessProtectedData) {
|
||||
@@ -621,6 +630,7 @@ export function ImageCanvasEditorView({
|
||||
toggleBackgroundSettings,
|
||||
toggleMinimap,
|
||||
} = useImageCanvasEditorChrome({ openEditorLoginModal });
|
||||
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
||||
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
||||
layers,
|
||||
setLayers,
|
||||
@@ -1029,6 +1039,7 @@ export function ImageCanvasEditorView({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1043,8 +1054,10 @@ export function ImageCanvasEditorView({
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
}),
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayers,
|
||||
@@ -1064,6 +1077,7 @@ export function ImageCanvasEditorView({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
@@ -1332,6 +1346,8 @@ export function ImageCanvasEditorView({
|
||||
moveQuickEditSelectionPointer,
|
||||
endUiAssetExtractionPointer,
|
||||
endQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtraction,
|
||||
appendUiAssetExtractionReferences,
|
||||
removeUiAssetExtractionReference,
|
||||
@@ -1566,6 +1582,30 @@ export function ImageCanvasEditorView({
|
||||
onCloseImageContextMenu: () => setImageContextMenu(null),
|
||||
});
|
||||
resetCanvasInteractionStateRef.current = clearActiveInteraction;
|
||||
useEffect(() => {
|
||||
if (!isAccountPaymentModalOpen) {
|
||||
return;
|
||||
}
|
||||
clearActiveInteraction();
|
||||
cancelUiAssetExtractionPointer();
|
||||
cancelQuickEditSelectionPointer();
|
||||
}, [
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
clearActiveInteraction,
|
||||
isAccountPaymentModalOpen,
|
||||
]);
|
||||
const openAccountPaymentModal = useCallback(() => {
|
||||
clearActiveInteraction();
|
||||
cancelUiAssetExtractionPointer();
|
||||
cancelQuickEditSelectionPointer();
|
||||
openRechargeOrRewardCodeModal();
|
||||
}, [
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
clearActiveInteraction,
|
||||
openRechargeOrRewardCodeModal,
|
||||
]);
|
||||
const handleCanvasPointerDownWithUiExtractionDismiss = useCallback(
|
||||
(event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (uiAssetExtractionState) {
|
||||
@@ -1762,6 +1802,7 @@ export function ImageCanvasEditorView({
|
||||
);
|
||||
|
||||
useImageCanvasKeyboardShortcuts({
|
||||
isInteractionPaused: isAccountPaymentModalOpen,
|
||||
generateDialogRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
@@ -1833,6 +1874,9 @@ export function ImageCanvasEditorView({
|
||||
if (isEditablePasteTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
if (isAccountPaymentModalOpen) {
|
||||
return;
|
||||
}
|
||||
const didPasteCanvasClipboard = pasteCanvasClipboard();
|
||||
if (didPasteCanvasClipboard) {
|
||||
event.preventDefault();
|
||||
@@ -1850,7 +1894,7 @@ export function ImageCanvasEditorView({
|
||||
return () => {
|
||||
window.removeEventListener('paste', handleClipboardPaste);
|
||||
};
|
||||
}, [addUploadedFiles, pasteCanvasClipboard]);
|
||||
}, [addUploadedFiles, isAccountPaymentModalOpen, pasteCanvasClipboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const blockBrowserZoom = (event: WheelEvent) => {
|
||||
@@ -1990,7 +2034,7 @@ export function ImageCanvasEditorView({
|
||||
resetProjectRenameError,
|
||||
exportCanvasAssets,
|
||||
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
|
||||
onOpenWallet: openRechargeOrRewardCodeModal,
|
||||
onOpenWallet: openAccountPaymentModal,
|
||||
onOpenAccount: () => {
|
||||
if (authUi?.user) {
|
||||
authUi.openAccountModal();
|
||||
@@ -2004,6 +2048,7 @@ export function ImageCanvasEditorView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isInteractionPaused: isAccountPaymentModalOpen,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -2025,8 +2070,12 @@ export function ImageCanvasEditorView({
|
||||
generateDialog,
|
||||
cropExpandPanel: generationSurface.cropExpandPanel,
|
||||
cropExpandSourceLayer: generationSurface.cropExpandSourceLayer,
|
||||
uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer,
|
||||
uiAssetExtractionState: isAccountPaymentModalOpen
|
||||
? null
|
||||
: uiAssetExtractionState,
|
||||
uiAssetExtractionSourceLayer: isAccountPaymentModalOpen
|
||||
? null
|
||||
: uiAssetExtractionSourceLayer,
|
||||
quickEditSelectionState,
|
||||
quickEditSelectionSourceLayer,
|
||||
generationComposerStyle,
|
||||
@@ -2182,6 +2231,7 @@ export function ImageCanvasEditorView({
|
||||
onRetry={loadRechargeCenter}
|
||||
onBuy={buyRechargeProduct}
|
||||
onConfirmNativePayment={confirmNativeWechatPayment}
|
||||
onCloseNativePayment={closeNativeWechatPayment}
|
||||
/>
|
||||
) : null}
|
||||
{rechargePaymentResult ? (
|
||||
|
||||
@@ -726,6 +726,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
|
||||
sourceWidth: 960,
|
||||
sourceHeight: 1280,
|
||||
promptText: '循环奔跑动作',
|
||||
screenColor: 'auto',
|
||||
resolution: '720p',
|
||||
ratio: 'same',
|
||||
frameCount: 48,
|
||||
|
||||
@@ -649,6 +649,8 @@ export function buildCharacterAnimationSubmissionPlan({
|
||||
sourceWidth: sourceLayer.originalWidth,
|
||||
sourceHeight: sourceLayer.originalHeight,
|
||||
promptText,
|
||||
// 与生图入口一致:默认 auto,由后端做背景色自动决策。
|
||||
screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
|
||||
resolution: panel.resolution,
|
||||
ratio: panel.ratio,
|
||||
frameCount: panel.frameCount,
|
||||
|
||||
@@ -43,6 +43,23 @@ function renderPanelDock(
|
||||
return props;
|
||||
}
|
||||
|
||||
function mockElementRect(
|
||||
element: Element,
|
||||
rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>,
|
||||
) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
...rect,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ImageCanvasPanelDockView', () => {
|
||||
it('renders panel dock actions and forwards common controls', () => {
|
||||
const props = renderPanelDock({
|
||||
@@ -98,9 +115,71 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the background picker visible when the selected color is white', () => {
|
||||
const props = renderPanelDock({
|
||||
canvasBackgroundColor: '#ffffff',
|
||||
canvasBackgroundHexValue: '#ffffff',
|
||||
isBackgroundSettingsOpen: true,
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
const hueButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色相',
|
||||
});
|
||||
const spectrumHandle = spectrumButton.querySelector(
|
||||
'.image-canvas-editor__background-spectrum-handle',
|
||||
) as HTMLElement;
|
||||
const hueHandle = hueButton.querySelector(
|
||||
'.image-canvas-editor__background-hue-handle',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(
|
||||
spectrumButton.style.getPropertyValue(
|
||||
'--image-canvas-background-hue-color',
|
||||
),
|
||||
).toBe('#0080ff');
|
||||
expect(
|
||||
spectrumHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-spectrum-handle-left',
|
||||
),
|
||||
).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))');
|
||||
expect(
|
||||
spectrumHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-spectrum-handle-top',
|
||||
),
|
||||
).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))');
|
||||
expect(
|
||||
hueHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-hue-handle-left',
|
||||
),
|
||||
).toBe(
|
||||
'clamp(0.39rem, 58.333333333333336%, calc(100% - 0.39rem))',
|
||||
);
|
||||
|
||||
mockElementRect(hueButton, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 20,
|
||||
});
|
||||
fireEvent.pointerDown(hueButton, {
|
||||
pointerId: 1,
|
||||
buttons: 1,
|
||||
clientX: 20,
|
||||
clientY: 10,
|
||||
});
|
||||
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
});
|
||||
|
||||
it('renders zoom and background settings with callback wiring', () => {
|
||||
const props = renderPanelDock({
|
||||
viewport: { x: 0, y: 0, scale: 0.5 },
|
||||
canvasBackgroundColor: '#ff0000',
|
||||
canvasBackgroundHexValue: '#ff0000',
|
||||
isZoomMenuOpen: true,
|
||||
isBackgroundSettingsOpen: true,
|
||||
});
|
||||
@@ -122,9 +201,22 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
|
||||
expect(panel.querySelector('input[type="color"]')).toBeNull();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '暖灰' }));
|
||||
fireEvent.change(within(panel).getByLabelText('自定义画布背景色'), {
|
||||
target: { value: '#ffffff' },
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
mockElementRect(spectrumButton, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
fireEvent.pointerDown(spectrumButton, {
|
||||
pointerId: 1,
|
||||
buttons: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('画布背景十六进制颜色'), {
|
||||
target: { value: '#abc' },
|
||||
@@ -135,7 +227,7 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
);
|
||||
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#804040');
|
||||
expect(props.onCanvasBackgroundHexChange).toHaveBeenCalledWith('#abc');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f8fafc');
|
||||
expect(props.onToggleBackgroundSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -19,8 +24,10 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
|
||||
import {
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||
@@ -54,6 +61,145 @@ type ImageCanvasPanelDockViewProps = {
|
||||
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
type CanvasBackgroundHsv = {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CANVAS_BACKGROUND_PICKER_HUE = 210;
|
||||
const ACHROMATIC_BACKGROUND_SATURATION_EPSILON = 0.001;
|
||||
|
||||
function hexToRgb(value: string) {
|
||||
const normalizedValue = normalizeCanvasBackgroundHex(value);
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
red: Number.parseInt(normalizedValue.slice(1, 3), 16),
|
||||
green: Number.parseInt(normalizedValue.slice(3, 5), 16),
|
||||
blue: Number.parseInt(normalizedValue.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsv(red: number, green: number, blue: number) {
|
||||
const normalizedRed = red / 255;
|
||||
const normalizedGreen = green / 255;
|
||||
const normalizedBlue = blue / 255;
|
||||
const maxValue = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const minValue = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const delta = maxValue - minValue;
|
||||
const saturation = maxValue === 0 ? 0 : delta / maxValue;
|
||||
let hue = 0;
|
||||
if (delta > 0) {
|
||||
if (maxValue === normalizedRed) {
|
||||
hue = 60 * (((normalizedGreen - normalizedBlue) / delta) % 6);
|
||||
} else if (maxValue === normalizedGreen) {
|
||||
hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2);
|
||||
} else {
|
||||
hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4);
|
||||
}
|
||||
}
|
||||
return {
|
||||
hue: (hue + 360) % 360,
|
||||
saturation,
|
||||
value: maxValue,
|
||||
};
|
||||
}
|
||||
|
||||
function hsvToHex({ hue, saturation, value }: CanvasBackgroundHsv) {
|
||||
const chroma = value * saturation;
|
||||
const normalizedHue = ((hue % 360) + 360) % 360;
|
||||
const secondComponent =
|
||||
chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
|
||||
const matchValue = value - chroma;
|
||||
const [redPrime, greenPrime, bluePrime] =
|
||||
normalizedHue < 60
|
||||
? [chroma, secondComponent, 0]
|
||||
: normalizedHue < 120
|
||||
? [secondComponent, chroma, 0]
|
||||
: normalizedHue < 180
|
||||
? [0, chroma, secondComponent]
|
||||
: normalizedHue < 240
|
||||
? [0, secondComponent, chroma]
|
||||
: normalizedHue < 300
|
||||
? [secondComponent, 0, chroma]
|
||||
: [chroma, 0, secondComponent];
|
||||
return `#${[redPrime, greenPrime, bluePrime]
|
||||
.map((channel) =>
|
||||
Math.round((channel + matchValue) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
)
|
||||
.join('')}`;
|
||||
}
|
||||
|
||||
function resolveCanvasBackgroundHsv(
|
||||
color: string,
|
||||
fallbackHue = DEFAULT_CANVAS_BACKGROUND_PICKER_HUE,
|
||||
): CanvasBackgroundHsv {
|
||||
const rgb = hexToRgb(color) ?? hexToRgb(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
if (!rgb) {
|
||||
return { hue: fallbackHue, saturation: 0.02, value: 0.99 };
|
||||
}
|
||||
const hsv = rgbToHsv(rgb.red, rgb.green, rgb.blue);
|
||||
return {
|
||||
...hsv,
|
||||
hue:
|
||||
hsv.saturation <= ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
||||
? fallbackHue
|
||||
: hsv.hue,
|
||||
};
|
||||
}
|
||||
|
||||
function getPointerRatio(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
axis: 'x' | 'y',
|
||||
) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const size = axis === 'x' ? rect.width : rect.height;
|
||||
if (!Number.isFinite(size) || size <= 0) {
|
||||
return 0.5;
|
||||
}
|
||||
const clientPosition = axis === 'x' ? event.clientX : event.clientY;
|
||||
if (!Number.isFinite(clientPosition)) {
|
||||
return 0.5;
|
||||
}
|
||||
const offset =
|
||||
axis === 'x' ? clientPosition - rect.left : clientPosition - rect.top;
|
||||
return clamp(offset / size, 0, 1);
|
||||
}
|
||||
|
||||
function resolveSpectrumPointerColor(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
currentColor: string,
|
||||
fallbackHue: number,
|
||||
) {
|
||||
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
||||
return hsvToHex({
|
||||
hue: currentHsv.hue,
|
||||
saturation: getPointerRatio(event, 'x'),
|
||||
value: 1 - getPointerRatio(event, 'y'),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHuePointerColor(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
currentColor: string,
|
||||
fallbackHue: number,
|
||||
) {
|
||||
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
||||
return hsvToHex({
|
||||
hue: getPointerRatio(event, 'x') * 360,
|
||||
saturation: currentHsv.saturation,
|
||||
value: currentHsv.value,
|
||||
});
|
||||
}
|
||||
|
||||
function isPrimaryPointerDrag(event: ReactPointerEvent<HTMLElement>) {
|
||||
return event.buttons === 1 || event.pointerType === 'touch';
|
||||
}
|
||||
|
||||
export function ImageCanvasPanelDockView({
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
@@ -81,6 +227,86 @@ export function ImageCanvasPanelDockView({
|
||||
onToggleMinimap,
|
||||
onMinimapPointerDown,
|
||||
}: ImageCanvasPanelDockViewProps) {
|
||||
const lastBackgroundHueRef = useRef(DEFAULT_CANVAS_BACKGROUND_PICKER_HUE);
|
||||
const backgroundHsv = resolveCanvasBackgroundHsv(
|
||||
canvasBackgroundColor,
|
||||
lastBackgroundHueRef.current,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
||||
) {
|
||||
lastBackgroundHueRef.current = backgroundHsv.hue;
|
||||
}
|
||||
}, [backgroundHsv.hue, backgroundHsv.saturation]);
|
||||
const backgroundHueColor = hsvToHex({
|
||||
hue: backgroundHsv.hue,
|
||||
saturation: 1,
|
||||
value: 1,
|
||||
});
|
||||
const backgroundSpectrumStyle = {
|
||||
'--image-canvas-background-hue-color': backgroundHueColor,
|
||||
} as CSSProperties;
|
||||
const backgroundSpectrumHandleStyle = {
|
||||
'--image-canvas-background-spectrum-handle-left': `clamp(0.42rem, ${backgroundHsv.saturation * 100}%, calc(100% - 0.42rem))`,
|
||||
'--image-canvas-background-spectrum-handle-top': `clamp(0.42rem, ${(1 - backgroundHsv.value) * 100}%, calc(100% - 0.42rem))`,
|
||||
} as CSSProperties;
|
||||
const backgroundHueHandleStyle = {
|
||||
'--image-canvas-background-hue-handle-left': `clamp(0.39rem, ${(backgroundHsv.hue / 360) * 100}%, calc(100% - 0.39rem))`,
|
||||
} as CSSProperties;
|
||||
const applySpectrumPointerColor = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onApplyCanvasBackgroundColor(
|
||||
resolveSpectrumPointerColor(
|
||||
event,
|
||||
canvasBackgroundColor,
|
||||
backgroundHsv.hue,
|
||||
),
|
||||
);
|
||||
};
|
||||
const applyHuePointerColor = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onApplyCanvasBackgroundColor(
|
||||
resolveHuePointerColor(event, canvasBackgroundColor, backgroundHsv.hue),
|
||||
);
|
||||
};
|
||||
const handleSpectrumKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const step = event.shiftKey ? 0.1 : 0.03;
|
||||
const nextHsv = { ...backgroundHsv };
|
||||
if (event.key === 'ArrowLeft') {
|
||||
nextHsv.saturation = clamp(nextHsv.saturation - step, 0, 1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextHsv.saturation = clamp(nextHsv.saturation + step, 0, 1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
nextHsv.value = clamp(nextHsv.value - step, 0, 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextHsv.value = clamp(nextHsv.value + step, 0, 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
||||
};
|
||||
const handleHueKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
const step = event.shiftKey ? 30 : 6;
|
||||
const nextHsv = { ...backgroundHsv };
|
||||
if (event.key === 'ArrowLeft') {
|
||||
nextHsv.hue = (nextHsv.hue - step + 360) % 360;
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextHsv.hue = (nextHsv.hue + step) % 360;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorIconButton
|
||||
@@ -212,15 +438,20 @@ export function ImageCanvasPanelDockView({
|
||||
/>
|
||||
<span>{canvasBackgroundColor}</span>
|
||||
</div>
|
||||
<label className="image-canvas-editor__background-spectrum">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="画布背景色相"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(event) =>
|
||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-spectrum"
|
||||
aria-label="画布背景色盘"
|
||||
aria-valuetext={canvasBackgroundColor}
|
||||
style={backgroundSpectrumStyle}
|
||||
onPointerDown={applySpectrumPointerColor}
|
||||
onPointerMove={(event) => {
|
||||
if (isPrimaryPointerDrag(event)) {
|
||||
applySpectrumPointerColor(event);
|
||||
}
|
||||
/>
|
||||
}}
|
||||
onKeyDown={handleSpectrumKeyDown}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-spectrum-surface"
|
||||
aria-hidden="true"
|
||||
@@ -228,18 +459,28 @@ export function ImageCanvasPanelDockView({
|
||||
<span
|
||||
className="image-canvas-editor__background-spectrum-handle"
|
||||
aria-hidden="true"
|
||||
style={backgroundSpectrumHandleStyle}
|
||||
/>
|
||||
</label>
|
||||
<label className="image-canvas-editor__background-hue">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="自定义画布背景色"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(event) =>
|
||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-hue"
|
||||
aria-label="画布背景色相"
|
||||
aria-valuetext={canvasBackgroundColor}
|
||||
onPointerDown={applyHuePointerColor}
|
||||
onPointerMove={(event) => {
|
||||
if (isPrimaryPointerDrag(event)) {
|
||||
applyHuePointerColor(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleHueKeyDown}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-hue-handle"
|
||||
aria-hidden="true"
|
||||
style={backgroundHueHandleStyle}
|
||||
/>
|
||||
</label>
|
||||
</button>
|
||||
<div
|
||||
className="image-canvas-editor__background-presets"
|
||||
aria-label="画布背景预设色"
|
||||
|
||||
@@ -45,6 +45,7 @@ export type ImageCanvasStageViewProps = {
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
publicationToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
isInteractionPaused?: boolean;
|
||||
isPanning: boolean;
|
||||
effectiveTool: CanvasTool;
|
||||
canvasBackgroundColor: string;
|
||||
@@ -194,6 +195,7 @@ export function ImageCanvasStageView({
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
publicationToolWrapRef,
|
||||
isInteractionPaused = false,
|
||||
isPanning,
|
||||
effectiveTool,
|
||||
canvasBackgroundColor,
|
||||
@@ -314,16 +316,20 @@ export function ImageCanvasStageView({
|
||||
<div
|
||||
ref={canvasViewportRef}
|
||||
className={`image-canvas-editor__viewport ${isPanning ? 'image-canvas-editor__viewport--panning' : ''} image-canvas-editor__viewport--tool-${effectiveTool}`}
|
||||
style={{ backgroundColor: canvasBackgroundColor }}
|
||||
style={{
|
||||
backgroundColor: canvasBackgroundColor,
|
||||
pointerEvents: isInteractionPaused ? 'none' : undefined,
|
||||
}}
|
||||
aria-label="画布工作区"
|
||||
onPointerDown={onCanvasPointerDown}
|
||||
onPointerMove={onCanvasPointerMove}
|
||||
onPointerUp={onCanvasPointerUp}
|
||||
onPointerCancel={onCanvasPointerUp}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDragLeave={onCanvasDragLeave}
|
||||
onDrop={onCanvasDrop}
|
||||
onContextMenu={onCanvasContextMenu}
|
||||
aria-disabled={isInteractionPaused || undefined}
|
||||
onPointerDown={isInteractionPaused ? undefined : onCanvasPointerDown}
|
||||
onPointerMove={isInteractionPaused ? undefined : onCanvasPointerMove}
|
||||
onPointerUp={isInteractionPaused ? undefined : onCanvasPointerUp}
|
||||
onPointerCancel={isInteractionPaused ? undefined : onCanvasPointerUp}
|
||||
onDragOver={isInteractionPaused ? undefined : onCanvasDragOver}
|
||||
onDragLeave={isInteractionPaused ? undefined : onCanvasDragLeave}
|
||||
onDrop={isInteractionPaused ? undefined : onCanvasDrop}
|
||||
onContextMenu={isInteractionPaused ? undefined : onCanvasContextMenu}
|
||||
>
|
||||
{uploadDropTarget === 'canvas' ? (
|
||||
<div
|
||||
|
||||
@@ -69,6 +69,12 @@ function createExternalTask(
|
||||
};
|
||||
}
|
||||
|
||||
function formatBackendTimestampMs(timestampMs: number) {
|
||||
const seconds = Math.floor(timestampMs / 1000);
|
||||
const micros = Math.max(0, Math.floor(timestampMs % 1000) * 1000);
|
||||
return `${seconds}.${String(micros).padStart(6, '0')}Z`;
|
||||
}
|
||||
|
||||
describe('ImageCanvasTaskSidebarView', () => {
|
||||
it('groups tasks by tabs and keeps running tasks before queued tasks', async () => {
|
||||
const focusExternalTask = vi.fn();
|
||||
@@ -158,6 +164,12 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
expect(activeTitles[1]).toContain('图标素材');
|
||||
expect(activeTitles[0]).toContain('已用时');
|
||||
expect(activeTitles[1]).not.toContain('已用时');
|
||||
const runningButton = screen.getByText('角色图片').closest('button');
|
||||
expect(
|
||||
runningButton?.querySelector(
|
||||
'.image-canvas-editor__task-sidebar-item-meta',
|
||||
)?.textContent,
|
||||
).toContain('已用时');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
@@ -190,8 +202,9 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
});
|
||||
|
||||
it('shows only current project external tasks with backend timing and phase detail', async () => {
|
||||
const startedAt = new Date(Date.now() - 65_000).toISOString();
|
||||
const completedAt = new Date(Date.now() - 20_000).toISOString();
|
||||
const nowMs = Date.now();
|
||||
const startedAt = formatBackendTimestampMs(nowMs - 65_000);
|
||||
const completedAt = formatBackendTimestampMs(nowMs - 20_000);
|
||||
listExternalGenerationTasksMock.mockImplementation(
|
||||
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
|
||||
Promise.resolve({
|
||||
@@ -218,7 +231,7 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
jobId: 'done-current',
|
||||
status: 'completed',
|
||||
requestPrompt: '已完成提示词',
|
||||
startedAt: new Date(Date.now() - 90_000).toISOString(),
|
||||
startedAt: formatBackendTimestampMs(nowMs - 90_000),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
progress: 100,
|
||||
@@ -245,7 +258,7 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
|
||||
expect(await screen.findByText('图片画布生成图片')).toBeTruthy();
|
||||
expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy();
|
||||
expect(screen.getByText(/正在生成第 2\/4 段。 · 已用时 1分/u)).toBeTruthy();
|
||||
expect(screen.getByText('正在生成第 2/4 段。')).toBeTruthy();
|
||||
expect(screen.queryByText(/35%/u)).toBeNull();
|
||||
expect(screen.getByText(/已用时 1分/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/总进度/u)).toBeNull();
|
||||
|
||||
@@ -54,8 +54,22 @@ function parseTaskTimeMs(value?: string | null) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
const trimmedValue = value.trim();
|
||||
const timestamp = Date.parse(trimmedValue);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
return timestamp;
|
||||
}
|
||||
const unixMicrosMatch = trimmedValue.match(/^(-?\d+)\.(\d{1,6})Z$/u);
|
||||
if (!unixMicrosMatch) {
|
||||
return undefined;
|
||||
}
|
||||
const [, secondsText = '', microsText = ''] = unixMicrosMatch;
|
||||
const seconds = Number(secondsText);
|
||||
const micros = Number(microsText.padEnd(6, '0'));
|
||||
if (!Number.isFinite(seconds) || !Number.isFinite(micros)) {
|
||||
return undefined;
|
||||
}
|
||||
return seconds * 1000 + Math.floor(micros / 1000);
|
||||
}
|
||||
|
||||
function formatElapsedMs(elapsedMs?: number) {
|
||||
@@ -683,17 +697,16 @@ export function ImageCanvasTaskSidebarView({
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const progressText = [
|
||||
item.progressDetail,
|
||||
isTimedTaskStatus(item.status) ? timeText : null,
|
||||
]
|
||||
const progressText = [item.progressDetail]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const titleText = [detailText, item.progressDetail, timeText]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const metaTimeText = isActiveTaskStatus(item.status)
|
||||
? null
|
||||
? isTimedTaskStatus(item.status)
|
||||
? timeText
|
||||
: null
|
||||
: timeText;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -23,7 +23,10 @@ import type {
|
||||
import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import { useImageCanvasGenerationSubmissionWorkflow } from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
import {
|
||||
applyQueuedEditorGenerationProject,
|
||||
useImageCanvasGenerationSubmissionWorkflow,
|
||||
} from './useImageCanvasGenerationSubmissionWorkflow';
|
||||
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
@@ -1455,6 +1458,82 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
const refreshTaskList = vi.fn();
|
||||
const refreshWalletBalance = vi.fn();
|
||||
const unresolvedProject = {
|
||||
projectId: 'editor-project-1',
|
||||
title: '队列项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal',
|
||||
resourceId: 'generation-dialog-background-removal',
|
||||
itemType: 'generation-dialog',
|
||||
dialog: {
|
||||
id: 'dialog-background-removal',
|
||||
status: 'generating',
|
||||
},
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-23T08:00:00.000Z',
|
||||
};
|
||||
const completedProject = {
|
||||
...unresolvedProject,
|
||||
layers: [
|
||||
{
|
||||
layerId: 'generation-dialog-background-removal',
|
||||
resourceId: 'generation-dialog-background-removal',
|
||||
itemType: 'generation-dialog',
|
||||
dialog: {
|
||||
id: 'dialog-background-removal',
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-background-removal-result',
|
||||
},
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-23T08:00:01.000Z',
|
||||
};
|
||||
loadEditorProjectMock
|
||||
.mockResolvedValueOnce(unresolvedProject)
|
||||
.mockResolvedValueOnce(completedProject);
|
||||
|
||||
const applyPromise = applyQueuedEditorGenerationProject(
|
||||
{ queueState: createQueueState() },
|
||||
'editor-project-1',
|
||||
applyProjectSnapshot,
|
||||
refreshTaskList,
|
||||
refreshWalletBalance,
|
||||
'dialog-background-removal',
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(refreshTaskList).toHaveBeenCalledTimes(1);
|
||||
expect(refreshWalletBalance).toHaveBeenCalledTimes(1);
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(650);
|
||||
await applyPromise;
|
||||
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
unresolvedProject,
|
||||
);
|
||||
expect(applyProjectSnapshot).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
completedProject,
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a queued generation pending when the frontend queue wait expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -101,6 +101,7 @@ type UiDesignAssetExtractionOptions = {
|
||||
|
||||
const EDITOR_GENERATION_QUEUE_POLL_INTERVAL_MS = 1600;
|
||||
const EDITOR_GENERATION_QUEUE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS = 650;
|
||||
|
||||
type GenerationSubmissionWorkflowOptions = {
|
||||
layers: CanvasLayer[];
|
||||
@@ -176,6 +177,31 @@ function queuedStateFromResponse(
|
||||
return response?.queueState ?? null;
|
||||
}
|
||||
|
||||
function projectHasUnresolvedGenerationDialog(
|
||||
project: EditorProjectSnapshot,
|
||||
dialogId: string | null | undefined,
|
||||
) {
|
||||
const normalizedDialogId = dialogId?.trim();
|
||||
if (!normalizedDialogId) {
|
||||
return false;
|
||||
}
|
||||
return project.layers.some((item) => {
|
||||
if (item.itemType !== 'generation-dialog') {
|
||||
return false;
|
||||
}
|
||||
const dialog =
|
||||
item.dialog && typeof item.dialog === 'object'
|
||||
? (item.dialog as Record<string, unknown>)
|
||||
: null;
|
||||
return (
|
||||
dialog?.id === normalizedDialogId &&
|
||||
(dialog.status === 'generating' ||
|
||||
typeof dialog.generatedLayerId !== 'string' ||
|
||||
dialog.generatedLayerId.trim() === '')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
|
||||
callback?.();
|
||||
}
|
||||
@@ -224,6 +250,8 @@ export async function applyQueuedEditorGenerationProject(
|
||||
applyProjectSnapshot: ((project: EditorProjectSnapshot) => void) | undefined,
|
||||
onQueuedGenerationTask?: () => void,
|
||||
onWalletBalanceMayHaveChanged?: () => void,
|
||||
completionDialogId?: string | null,
|
||||
transformProjectSnapshot?: (project: EditorProjectSnapshot) => EditorProjectSnapshot,
|
||||
) {
|
||||
const queueState = queuedStateFromResponse(response);
|
||||
if (!queueState) {
|
||||
@@ -240,7 +268,15 @@ export async function applyQueuedEditorGenerationProject(
|
||||
return true;
|
||||
}
|
||||
if (projectId && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(await loadEditorProject(projectId));
|
||||
const applyLoadedProject = (project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(transformProjectSnapshot?.(project) ?? project);
|
||||
};
|
||||
const project = await loadEditorProject(projectId);
|
||||
applyLoadedProject(project);
|
||||
if (projectHasUnresolvedGenerationDialog(project, completionDialogId)) {
|
||||
await delay(EDITOR_GENERATION_QUEUE_PROJECT_REFRESH_RETRY_MS);
|
||||
applyLoadedProject(await loadEditorProject(projectId));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
|
||||
const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
|
||||
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
@@ -46,6 +48,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
return {
|
||||
...actual,
|
||||
editEditorImage: editEditorImageMock,
|
||||
createEditorProjectResource: createEditorProjectResourceMock,
|
||||
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
|
||||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||||
@@ -54,6 +57,10 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
vi.mock('./ImageCanvasRasterEditModel', async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import('./ImageCanvasRasterEditModel')
|
||||
@@ -190,6 +197,14 @@ function GenerationWorkflowHarness({
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="layer-resources">
|
||||
{layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${layer.id}:${layer.resourceId}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`,
|
||||
)
|
||||
.join('|')}
|
||||
</span>
|
||||
<span data-testid="dialog">
|
||||
{activeDialog
|
||||
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
||||
@@ -823,6 +838,27 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/crop-expand.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
legacyPublicPath:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
});
|
||||
createEditorProjectResourceMock.mockResolvedValue({
|
||||
resourceId: 'resource-crop-expand',
|
||||
projectId: 'project-1',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
width: 380,
|
||||
height: 280,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
});
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
@@ -1662,6 +1698,63 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(screen.getByTestId('fit-count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('uploads a project crop-expand result before adding it to the canvas', async () => {
|
||||
renderCropExpandImageMock.mockResolvedValueOnce({
|
||||
imageSrc: 'data:image/png;base64,Y3JvcC1leHBhbmRlZA==',
|
||||
width: 380,
|
||||
height: 280,
|
||||
});
|
||||
render(<GenerationWorkflowHarness projectId="project-1" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开裁扩' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成裁扩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_crop_expand_image',
|
||||
entityId: 'project-1',
|
||||
metadata: {
|
||||
editor_project_id: 'project-1',
|
||||
source_resource_id: 'resource-source',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
const uploadCallOrder =
|
||||
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0];
|
||||
const resourceCreateCallOrder =
|
||||
createEditorProjectResourceMock.mock.invocationCallOrder[0];
|
||||
expect(uploadCallOrder).toBeDefined();
|
||||
expect(resourceCreateCallOrder).toBeDefined();
|
||||
expect(uploadCallOrder!).toBeLessThan(resourceCreateCallOrder!);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
width: 380,
|
||||
height: 280,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('layer-resources').textContent).toContain(
|
||||
'layer-crop-expand-1:resource-crop-expand:generated-character-drafts/editor/crop-expand/project-1/result.png:asset-object-crop-expand',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain(
|
||||
'layer-crop-expand-1:源图 裁扩:resource-source:-:data:image',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves private character image before removing background', async () => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,resolved-character',
|
||||
@@ -1775,6 +1868,115 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a just-created crop layer when applying a background removal project snapshot', async () => {
|
||||
const applyProjectSnapshot = vi.fn();
|
||||
let resolveBackgroundRemoval: ((value: unknown) => void) | null = null;
|
||||
removeImageBackgroundMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveBackgroundRemoval = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<GenerationWorkflowHarness
|
||||
projectId="project-1"
|
||||
applyProjectSnapshot={applyProjectSnapshot}
|
||||
initialLayers={[
|
||||
createLayer({
|
||||
id: 'layer-crop-expand-1',
|
||||
resourceId: 'resource-crop-expand',
|
||||
title: '源图 裁扩',
|
||||
src: '/generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-source',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '去除背景' }));
|
||||
|
||||
await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled());
|
||||
await act(async () => {
|
||||
resolveBackgroundRemoval?.({
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/background-removal/project-result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/background-removal/project-result.png',
|
||||
assetObjectId: 'asset-object-background-removal',
|
||||
width: 512,
|
||||
height: 768,
|
||||
taskId: 'background-removal-project-task',
|
||||
elapsedMs: 1234,
|
||||
provider: 'BiRefNet',
|
||||
project: {
|
||||
projectId: 'project-1',
|
||||
title: '队列项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-background-removal-result',
|
||||
resourceId: 'resource-background-removal',
|
||||
title: '源图 裁扩 去背景',
|
||||
x: 480,
|
||||
y: 140,
|
||||
width: 512,
|
||||
height: 768,
|
||||
originalWidth: 512,
|
||||
originalHeight: 768,
|
||||
zIndex: 9,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-background-removal',
|
||||
projectId: 'project-1',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/background-removal/project-result.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/background-removal/project-result.png',
|
||||
assetObjectId: 'asset-object-background-removal',
|
||||
width: 512,
|
||||
height: 768,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: 'resource-crop-expand',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-23T08:00:01.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const appliedProject = applyProjectSnapshot.mock.calls[0]?.[0];
|
||||
expect(appliedProject?.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-crop-expand-1',
|
||||
resourceId: 'resource-crop-expand',
|
||||
title: '源图 裁扩',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-background-removal-result',
|
||||
resourceId: 'resource-background-removal',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(appliedProject?.resources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
resourceId: 'resource-crop-expand',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/crop-expand/project-1/result.png',
|
||||
assetObjectId: 'asset-object-crop-expand',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens UI design extraction as a mark selection state before submitting', () => {
|
||||
render(<GenerationWorkflowHarness />);
|
||||
|
||||
|
||||
@@ -11,11 +11,14 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
type EditorAssetSnapshot,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
@@ -33,6 +36,7 @@ import type {
|
||||
SpecFormValues,
|
||||
SpecGenerationType,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { resizeCropExpandFrame } from './ImageCanvasCropExpandModel';
|
||||
import {
|
||||
appendCharacterReference,
|
||||
appendGenerationReference,
|
||||
@@ -125,6 +129,103 @@ type RememberedImageGenerationOptions = {
|
||||
imageSize: string;
|
||||
};
|
||||
|
||||
function dataUrlToImageFile(dataUrl: string, fileName: string) {
|
||||
const [header = '', payload = ''] = dataUrl.split(',');
|
||||
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
|
||||
const type = mimeMatch?.[1] ?? 'image/png';
|
||||
const isBase64 = Boolean(mimeMatch?.[2]);
|
||||
const binary = isBase64
|
||||
? typeof atob === 'function'
|
||||
? atob(payload)
|
||||
: Buffer.from(payload, 'base64').toString('binary')
|
||||
: decodeURIComponent(payload);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type });
|
||||
}
|
||||
|
||||
function createProjectResourceSnapshotFromLayer(
|
||||
projectId: string,
|
||||
layer: CanvasLayer,
|
||||
): EditorProjectResourceSnapshot | null {
|
||||
const imageSrc = layer.objectKey?.trim()
|
||||
? `/${layer.objectKey.trim().replace(/^\/+/u, '')}`
|
||||
: layer.src.trim();
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
resourceId: layer.resourceId,
|
||||
projectId,
|
||||
label: layer.title,
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
width: layer.originalWidth,
|
||||
height: layer.originalHeight,
|
||||
sourceType: layer.sourceType,
|
||||
prompt: layer.prompt,
|
||||
actualPrompt: layer.actualPrompt,
|
||||
model: layer.model,
|
||||
provider: layer.provider,
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectLayerSnapshotFromLayer(
|
||||
layer: CanvasLayer,
|
||||
): EditorProjectLayerSnapshot {
|
||||
return {
|
||||
layerId: layer.id,
|
||||
resourceId: layer.resourceId,
|
||||
title: layer.title,
|
||||
x: layer.x,
|
||||
y: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
originalWidth: layer.originalWidth,
|
||||
originalHeight: layer.originalHeight,
|
||||
zIndex: layer.zIndex,
|
||||
sourceType: layer.sourceType,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
mediaType: layer.mediaType,
|
||||
objectKey: layer.objectKey,
|
||||
assetObjectId: layer.assetObjectId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function preserveSourceLayerInProjectSnapshot(
|
||||
project: EditorProjectSnapshot,
|
||||
sourceLayer: CanvasLayer,
|
||||
): EditorProjectSnapshot {
|
||||
if (
|
||||
project.layers.some((layer) => layer.layerId === sourceLayer.id) ||
|
||||
sourceLayer.resourceId.startsWith('generation-dialog:')
|
||||
) {
|
||||
return project;
|
||||
}
|
||||
const sourceResource = project.resources.some(
|
||||
(resource) => resource.resourceId === sourceLayer.resourceId,
|
||||
)
|
||||
? null
|
||||
: createProjectResourceSnapshotFromLayer(project.projectId, sourceLayer);
|
||||
return {
|
||||
...project,
|
||||
resources: sourceResource
|
||||
? [...project.resources, sourceResource]
|
||||
: project.resources,
|
||||
layers: [createProjectLayerSnapshotFromLayer(sourceLayer), ...project.layers],
|
||||
};
|
||||
}
|
||||
|
||||
function findSourceGenerationDialog(
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
sourceLayer: CanvasLayer,
|
||||
@@ -1323,12 +1424,77 @@ export function useImageCanvasGenerationWorkflow({
|
||||
});
|
||||
layerCounterRef.current += 1;
|
||||
const cropExpandIndex = layerCounterRef.current;
|
||||
const cropExpandTitle = `${cropExpandSourceLayer.title} 裁扩`;
|
||||
const cropExpandProjectId = projectId?.trim();
|
||||
let cropExpandResourceId = `local-resource-crop-expand-${cropExpandIndex}`;
|
||||
let cropExpandImageSrc = result.imageSrc;
|
||||
let cropExpandObjectKey: string | null = null;
|
||||
let cropExpandAssetObjectId: string | null = null;
|
||||
let cropExpandSourceResourceId: string | null =
|
||||
cropExpandSourceLayer.resourceId;
|
||||
let cropExpandAssetKind = cropExpandSourceLayer.assetKind;
|
||||
if (cropExpandProjectId) {
|
||||
const cropExpandFile = dataUrlToImageFile(
|
||||
result.imageSrc,
|
||||
`crop-expand-${cropExpandIndex}.png`,
|
||||
);
|
||||
const uploadedCropExpand = await uploadEditorMediaAssetFile(
|
||||
cropExpandFile,
|
||||
'image',
|
||||
{
|
||||
assetKind: 'editor_crop_expand_image',
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'crop-expand',
|
||||
cropExpandProjectId,
|
||||
`${Date.now()}`,
|
||||
],
|
||||
entityId: cropExpandProjectId,
|
||||
metadata: {
|
||||
editor_project_id: cropExpandProjectId,
|
||||
source_resource_id: cropExpandSourceLayer.resourceId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const cropExpandResource = await createEditorProjectResource(
|
||||
cropExpandProjectId,
|
||||
{
|
||||
imageSrc: uploadedCropExpand.legacyPublicPath,
|
||||
objectKey: uploadedCropExpand.objectKey,
|
||||
assetObjectId: uploadedCropExpand.assetObjectId,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
sourceType: 'generated',
|
||||
prompt: cropExpandSourceLayer.prompt,
|
||||
actualPrompt: cropExpandSourceLayer.actualPrompt,
|
||||
model: cropExpandSourceLayer.model,
|
||||
provider: cropExpandSourceLayer.provider,
|
||||
taskId: cropExpandSourceLayer.taskId,
|
||||
sourceResourceId: cropExpandSourceLayer.resourceId,
|
||||
assetKind: cropExpandSourceLayer.assetKind,
|
||||
generationInputs: cropExpandSourceLayer.generationInputs,
|
||||
},
|
||||
);
|
||||
cropExpandResourceId = cropExpandResource.resourceId;
|
||||
cropExpandImageSrc = cropExpandResource.imageSrc;
|
||||
cropExpandObjectKey =
|
||||
cropExpandResource.objectKey ?? uploadedCropExpand.objectKey;
|
||||
cropExpandAssetObjectId =
|
||||
cropExpandResource.assetObjectId ??
|
||||
uploadedCropExpand.assetObjectId;
|
||||
cropExpandSourceResourceId =
|
||||
cropExpandResource.sourceResourceId ??
|
||||
cropExpandSourceLayer.resourceId;
|
||||
cropExpandAssetKind =
|
||||
(cropExpandResource.assetKind as CanvasLayer['assetKind']) ??
|
||||
cropExpandSourceLayer.assetKind;
|
||||
}
|
||||
const nextLayer: CanvasLayer = {
|
||||
...cropExpandSourceLayer,
|
||||
id: `layer-crop-expand-${cropExpandIndex}`,
|
||||
resourceId: `local-resource-crop-expand-${cropExpandIndex}`,
|
||||
title: `${cropExpandSourceLayer.title} 裁扩`,
|
||||
src: result.imageSrc,
|
||||
resourceId: cropExpandResourceId,
|
||||
title: cropExpandTitle,
|
||||
src: cropExpandImageSrc,
|
||||
x: cropExpandSourceLayer.x + cropExpandSourceLayer.width + 32,
|
||||
y: cropExpandSourceLayer.y,
|
||||
width: result.width,
|
||||
@@ -1337,10 +1503,11 @@ export function useImageCanvasGenerationWorkflow({
|
||||
originalHeight: result.height,
|
||||
zIndex: cropExpandIndex + 10,
|
||||
sourceType: 'generated',
|
||||
sourceResourceId: cropExpandSourceLayer.resourceId,
|
||||
objectKey: null,
|
||||
assetObjectId: null,
|
||||
sourceResourceId: cropExpandSourceResourceId,
|
||||
objectKey: cropExpandObjectKey,
|
||||
assetObjectId: cropExpandAssetObjectId,
|
||||
sourceAssetId: null,
|
||||
assetKind: cropExpandAssetKind,
|
||||
};
|
||||
captureCanvasHistory();
|
||||
appendCanvasLayersWithResources([nextLayer]);
|
||||
@@ -1367,6 +1534,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
fitLayers,
|
||||
layerCounterRef,
|
||||
persistGeneratedAsset,
|
||||
projectId,
|
||||
selectSingleLayer,
|
||||
setActiveSidebarPanel,
|
||||
]);
|
||||
@@ -1397,6 +1565,14 @@ export function useImageCanvasGenerationWorkflow({
|
||||
})
|
||||
: undefined;
|
||||
const backgroundRemovalDialogId = backgroundRemovalPlacement?.dialogId;
|
||||
const applyBackgroundRemovalProjectSnapshot =
|
||||
projectId && applyProjectSnapshot
|
||||
? (project: EditorProjectSnapshot) => {
|
||||
applyProjectSnapshot(
|
||||
preserveSourceLayerInProjectSnapshot(project, sourceLayer),
|
||||
);
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
const sourceObjectKey = sourceLayer.objectKey?.trim();
|
||||
const sourceImageSrc = sourceObjectKey
|
||||
@@ -1428,12 +1604,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
applyProjectSnapshot,
|
||||
refreshTaskListForQueuedGeneration,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
backgroundRemovalDialogId,
|
||||
applyBackgroundRemovalProjectSnapshot
|
||||
? (project) =>
|
||||
preserveSourceLayerInProjectSnapshot(project, sourceLayer)
|
||||
: undefined,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (result.project && applyProjectSnapshot) {
|
||||
applyProjectSnapshot(result.project);
|
||||
if (result.project && applyBackgroundRemovalProjectSnapshot) {
|
||||
applyBackgroundRemovalProjectSnapshot(result.project);
|
||||
return;
|
||||
}
|
||||
if (backgroundRemovalPlacement?.placeholder && applyProjectSnapshot) {
|
||||
@@ -1469,14 +1650,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
setActiveSidebarPanel('layers');
|
||||
} catch (error) {
|
||||
if (backgroundRemovalDialogId) {
|
||||
updateCanvasGenerationDialogById(backgroundRemovalDialogId, (dialog) => ({
|
||||
...dialog,
|
||||
status: 'failed',
|
||||
errorMessage:
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: '去除背景失败',
|
||||
}));
|
||||
updateCanvasGenerationDialogById(
|
||||
backgroundRemovalDialogId,
|
||||
(dialog) => ({
|
||||
...dialog,
|
||||
status: 'failed',
|
||||
errorMessage:
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: '去除背景失败',
|
||||
}),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -1939,6 +2123,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cancelUiAssetExtractionPointer = useCallback(() => {
|
||||
setUiAssetExtractionState((currentState) =>
|
||||
currentState?.draftMark
|
||||
? {
|
||||
...currentState,
|
||||
draftMark: null,
|
||||
}
|
||||
: currentState,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const endQuickEditSelectionPointer = useCallback(() => {
|
||||
const currentSelectionState = quickEditSelectionStateRef.current;
|
||||
if (!currentSelectionState?.draftMark) {
|
||||
@@ -1998,6 +2193,17 @@ export function useImageCanvasGenerationWorkflow({
|
||||
);
|
||||
}, [setGenerateDialog, setQuickEditPanel, updateQuickEditSelectionState]);
|
||||
|
||||
const cancelQuickEditSelectionPointer = useCallback(() => {
|
||||
updateQuickEditSelectionState((currentState) =>
|
||||
currentState?.draftMark
|
||||
? {
|
||||
...currentState,
|
||||
draftMark: null,
|
||||
}
|
||||
: currentState,
|
||||
);
|
||||
}, [updateQuickEditSelectionState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!quickEditSelectionState) {
|
||||
return;
|
||||
@@ -2168,6 +2374,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
moveQuickEditSelectionPointer,
|
||||
endUiAssetExtractionPointer,
|
||||
endQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtraction,
|
||||
submitUiAssetExtraction,
|
||||
characterAnimationPanel: effectiveCharacterAnimationPanel,
|
||||
@@ -2272,6 +2480,8 @@ export function useImageCanvasGenerationWorkflow({
|
||||
moveQuickEditSelectionPointer,
|
||||
endUiAssetExtractionPointer,
|
||||
endQuickEditSelectionPointer,
|
||||
cancelUiAssetExtractionPointer,
|
||||
cancelQuickEditSelectionPointer,
|
||||
cancelUiAssetExtraction,
|
||||
submitUiAssetExtraction,
|
||||
setEffectiveCharacterAnimationPanel,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
@@ -58,6 +63,7 @@ function KeyboardShortcutsHarness({
|
||||
initialGenerateDialog = null,
|
||||
initialQuickEditPanel = null,
|
||||
initialTool = 'select',
|
||||
isInteractionPaused = false,
|
||||
undoCanvasChange = vi.fn(),
|
||||
redoCanvasChange = vi.fn(),
|
||||
deleteLayerById = vi.fn(),
|
||||
@@ -79,12 +85,15 @@ function KeyboardShortcutsHarness({
|
||||
startProjectRename = vi.fn(),
|
||||
exportCanvasAssets = vi.fn(),
|
||||
closeEditorChromePanels = vi.fn(),
|
||||
setIsSpacePanning: setIsSpacePanningOverride,
|
||||
setShiftPressed: setShiftPressedOverride,
|
||||
}: {
|
||||
selectedLayerId?: string | null;
|
||||
selectedLayerIds?: string[];
|
||||
initialGenerateDialog?: GenerateDialogState | null;
|
||||
initialQuickEditPanel?: QuickEditPanelState | null;
|
||||
initialTool?: CanvasTool;
|
||||
isInteractionPaused?: boolean;
|
||||
undoCanvasChange?: () => void;
|
||||
redoCanvasChange?: () => void;
|
||||
deleteLayerById?: (layerId: string | null) => void;
|
||||
@@ -108,6 +117,8 @@ function KeyboardShortcutsHarness({
|
||||
startProjectRename?: () => void;
|
||||
exportCanvasAssets?: () => void;
|
||||
closeEditorChromePanels?: () => void;
|
||||
setIsSpacePanning?: Dispatch<SetStateAction<boolean>>;
|
||||
setShiftPressed?: Dispatch<SetStateAction<boolean>>;
|
||||
}) {
|
||||
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
|
||||
const [generateDialog, setGenerateDialogState] =
|
||||
@@ -141,8 +152,8 @@ function KeyboardShortcutsHarness({
|
||||
const [, setIsUiDesignSpecMenuOpen] = useState(true);
|
||||
const [, setIsPickingUiDesignSpecFromCanvas] =
|
||||
useState(true);
|
||||
const [isSpacePanning, setIsSpacePanning] = useState(false);
|
||||
const [shiftPressed, setShiftPressed] = useState(false);
|
||||
const [isSpacePanning, setIsSpacePanningState] = useState(false);
|
||||
const [shiftPressed, setShiftPressedState] = useState(false);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(generateDialog);
|
||||
const selectedLayerIdRef = useRef<string | null>(selectedLayerId);
|
||||
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
|
||||
@@ -151,6 +162,7 @@ function KeyboardShortcutsHarness({
|
||||
selectedLayerIdsRef.current = selectedLayerIds;
|
||||
|
||||
useImageCanvasKeyboardShortcuts({
|
||||
isInteractionPaused,
|
||||
generateDialogRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
@@ -200,8 +212,8 @@ function KeyboardShortcutsHarness({
|
||||
setIsPickingIconSpecFromCanvas,
|
||||
setIsUiDesignSpecMenuOpen,
|
||||
setIsPickingUiDesignSpecFromCanvas,
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
setIsSpacePanning: setIsSpacePanningOverride ?? setIsSpacePanningState,
|
||||
setShiftPressed: setShiftPressedOverride ?? setShiftPressedState,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -483,6 +495,82 @@ describe('useImageCanvasKeyboardShortcuts', () => {
|
||||
expect(screen.getByTestId('space-panning').textContent).toBe('false');
|
||||
});
|
||||
|
||||
it('pauses canvas shortcuts and clears held interaction state while account dialogs are open', () => {
|
||||
const selectAllCanvasObjects = vi.fn();
|
||||
const deleteSelectedCanvasObjects = vi.fn();
|
||||
const clearCanvasSelection = vi.fn();
|
||||
const switchTool = vi.fn();
|
||||
const setIsSpacePanning = vi.fn();
|
||||
const setShiftPressed = vi.fn();
|
||||
const { rerender } = render(
|
||||
<KeyboardShortcutsHarness
|
||||
selectedLayerIds={['layer-selected']}
|
||||
selectAllCanvasObjects={selectAllCanvasObjects}
|
||||
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
|
||||
clearCanvasSelection={clearCanvasSelection}
|
||||
switchTool={switchTool}
|
||||
setIsSpacePanning={setIsSpacePanning}
|
||||
setShiftPressed={setShiftPressed}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: 'Shift', code: 'ShiftLeft' });
|
||||
fireEvent.keyDown(window, { key: ' ', code: 'Space' });
|
||||
});
|
||||
expect(setShiftPressed).toHaveBeenLastCalledWith(true);
|
||||
expect(setIsSpacePanning).toHaveBeenLastCalledWith(true);
|
||||
|
||||
rerender(
|
||||
<KeyboardShortcutsHarness
|
||||
selectedLayerIds={['layer-selected']}
|
||||
isInteractionPaused
|
||||
selectAllCanvasObjects={selectAllCanvasObjects}
|
||||
deleteSelectedCanvasObjects={deleteSelectedCanvasObjects}
|
||||
clearCanvasSelection={clearCanvasSelection}
|
||||
switchTool={switchTool}
|
||||
setIsSpacePanning={setIsSpacePanning}
|
||||
setShiftPressed={setShiftPressed}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(setShiftPressed).toHaveBeenLastCalledWith(false);
|
||||
expect(setIsSpacePanning).toHaveBeenLastCalledWith(false);
|
||||
|
||||
const selectAllEvent = new KeyboardEvent('keydown', {
|
||||
key: 'a',
|
||||
code: 'KeyA',
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const deleteEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Delete',
|
||||
code: 'Delete',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
const escapeEvent = new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
code: 'Escape',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
window.dispatchEvent(selectAllEvent);
|
||||
window.dispatchEvent(deleteEvent);
|
||||
window.dispatchEvent(escapeEvent);
|
||||
fireEvent.keyDown(window, { key: 'v', code: 'KeyV' });
|
||||
|
||||
expect(selectAllCanvasObjects).not.toHaveBeenCalled();
|
||||
expect(deleteSelectedCanvasObjects).not.toHaveBeenCalled();
|
||||
expect(clearCanvasSelection).not.toHaveBeenCalled();
|
||||
expect(switchTool).not.toHaveBeenCalled();
|
||||
expect(selectAllEvent.defaultPrevented).toBe(false);
|
||||
expect(deleteEvent.defaultPrevented).toBe(false);
|
||||
expect(escapeEvent.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('nudges selected canvas objects with arrow keys', () => {
|
||||
const nudgeSelectedCanvasObjects = vi.fn();
|
||||
render(
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type UseImageCanvasKeyboardShortcutsOptions = {
|
||||
isInteractionPaused?: boolean;
|
||||
generateDialogRef: RefObject<GenerateDialogState | null>;
|
||||
selectedLayerIdRef: RefObject<string | null>;
|
||||
selectedLayerIdsRef?: RefObject<string[]>;
|
||||
@@ -156,6 +157,7 @@ function getArrowDelta(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
export function useImageCanvasKeyboardShortcuts({
|
||||
isInteractionPaused = false,
|
||||
generateDialogRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
@@ -202,6 +204,14 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setIsSpacePanning,
|
||||
setShiftPressed,
|
||||
}: UseImageCanvasKeyboardShortcutsOptions) {
|
||||
useEffect(() => {
|
||||
if (!isInteractionPaused) {
|
||||
return;
|
||||
}
|
||||
setShiftPressed(false);
|
||||
setIsSpacePanning(false);
|
||||
}, [isInteractionPaused, setIsSpacePanning, setShiftPressed]);
|
||||
|
||||
useEffect(() => {
|
||||
const closeTransientEditorPanels = () => {
|
||||
closeEditorChromePanels();
|
||||
@@ -253,6 +263,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isInteractionPaused) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isCtrlShortcut(event) &&
|
||||
event.code === 'KeyZ' &&
|
||||
@@ -461,6 +474,9 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
setIsSpacePanning(true);
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (isInteractionPaused) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Shift') {
|
||||
setShiftPressed(false);
|
||||
}
|
||||
@@ -501,6 +517,7 @@ export function useImageCanvasKeyboardShortcuts({
|
||||
exportCanvasAssets,
|
||||
fitLayers,
|
||||
generateDialogRef,
|
||||
isInteractionPaused,
|
||||
moveSelectedCanvasLayers,
|
||||
nudgeSelectedCanvasObjects,
|
||||
requestRemoveCanvasGenerationDialog,
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
@@ -88,10 +92,12 @@ function createDeferred<T>() {
|
||||
|
||||
function ProjectPersistenceHarness({
|
||||
canAccessProtectedData = true,
|
||||
initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
initialGenerationDialogs = [],
|
||||
onProjectAccessLost,
|
||||
}: {
|
||||
canAccessProtectedData?: boolean;
|
||||
initialCanvasBackgroundColor?: string;
|
||||
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
onProjectAccessLost?: () => void;
|
||||
}) {
|
||||
@@ -99,6 +105,9 @@ function ProjectPersistenceHarness({
|
||||
const [generationDialogs, setGenerationDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
>(initialGenerationDialogs);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
||||
initialCanvasBackgroundColor,
|
||||
);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -110,6 +119,7 @@ function ProjectPersistenceHarness({
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
|
||||
const selectedLayerRef = useRef<string | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
const openEditorLoginModalRef = useRef(vi.fn());
|
||||
@@ -117,17 +127,27 @@ function ProjectPersistenceHarness({
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
canvasGenerationDialogsRef.current = generationDialogs;
|
||||
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
||||
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
}, []);
|
||||
const setLayerCounter = useCallback((value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
}, []);
|
||||
const applyCanvasBackgroundColor = useCallback((color: string) => {
|
||||
const normalizedColor = normalizeCanvasBackgroundHex(color);
|
||||
if (!normalizedColor) {
|
||||
return false;
|
||||
}
|
||||
setCanvasBackgroundColor(normalizedColor);
|
||||
return true;
|
||||
}, []);
|
||||
const persistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -140,8 +160,9 @@ function ProjectPersistenceHarness({
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
}),
|
||||
[selectSingleLayer, setLayerCounter],
|
||||
[applyCanvasBackgroundColor, selectSingleLayer, setLayerCounter],
|
||||
);
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
@@ -150,6 +171,7 @@ function ProjectPersistenceHarness({
|
||||
layers,
|
||||
canvasGenerationDialogs: generationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
openEditorLoginModal: openEditorLoginModalRef.current,
|
||||
@@ -187,6 +209,7 @@ function ProjectPersistenceHarness({
|
||||
)
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="background">{canvasBackgroundColor}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -335,6 +358,22 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
move generation again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCanvasBackgroundColor(' #ABC ');
|
||||
}}
|
||||
>
|
||||
change background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCanvasBackgroundColor('#not-a-color');
|
||||
}}
|
||||
>
|
||||
change invalid background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -523,6 +562,89 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores the persisted canvas background color without autosaving on load', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '自定义背景画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#112233',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('background').textContent).toBe('#112233');
|
||||
});
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves canvas background color changes in project layout and session cache', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'change background' }).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
||||
const cachedRaw = globalThis.sessionStorage.getItem(
|
||||
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
||||
);
|
||||
expect(cachedRaw).toBeTruthy();
|
||||
expect(cachedRaw).not.toContain(' #ABC ');
|
||||
const cached = JSON.parse(cachedRaw ?? '{}') as {
|
||||
project?: EditorProjectSnapshot;
|
||||
};
|
||||
expect(cached.project?.layers).toEqual([
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: [
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'change invalid background' }).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('persists a static project cover snapshot resource after loading drawable layers', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
canvasDisplayViewportToViewport,
|
||||
type CanvasLayerResourceMetadata,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
hydrateLayer,
|
||||
isInlineEditorMediaSource,
|
||||
serializeCanvasLayout,
|
||||
@@ -64,6 +65,7 @@ type ImageCanvasProjectPersistenceRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||
canvasBackgroundColorRef: RefObject<string>;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceSetters = {
|
||||
@@ -76,6 +78,7 @@ type ImageCanvasProjectPersistenceSetters = {
|
||||
restoreCanvasGenerationDialogs: (
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
) => void;
|
||||
applyCanvasBackgroundColor: (color: string) => boolean;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceOptions = {
|
||||
@@ -84,6 +87,7 @@ type ImageCanvasProjectPersistenceOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
viewport: CanvasViewport;
|
||||
canvasBackgroundColor: string;
|
||||
isViewportInteracting: boolean;
|
||||
canAccessProtectedData: boolean;
|
||||
currentUserId?: string | null;
|
||||
@@ -293,6 +297,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
currentUserId,
|
||||
@@ -322,6 +327,7 @@ export function useImageCanvasProjectPersistence({
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
} = setters;
|
||||
|
||||
const clearPendingProjectLayoutSave = useCallback(() => {
|
||||
@@ -560,6 +566,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers: nextLayers,
|
||||
canvasGenerationDialogs:
|
||||
refs.canvasGenerationDialogsRef.current,
|
||||
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -616,11 +623,8 @@ export function useImageCanvasProjectPersistence({
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||
project.layers,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(project.layers, resourcesById, currentUserId);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
@@ -630,8 +634,12 @@ export function useImageCanvasProjectPersistence({
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
refs.canvasGenerationDialogsRef.current = generationDialogs;
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
applyCanvasBackgroundColor(
|
||||
canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
);
|
||||
},
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
clearPendingProjectLayoutSave,
|
||||
currentUserId,
|
||||
refs,
|
||||
@@ -761,6 +769,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers: serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
}),
|
||||
},
|
||||
{ delayMs: 450 },
|
||||
@@ -769,6 +778,7 @@ export function useImageCanvasProjectPersistence({
|
||||
}, [
|
||||
isProjectReady,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
layers,
|
||||
persistProjectCoverSnapshot,
|
||||
|
||||
@@ -421,6 +421,29 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接移动图层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
interaction.setShiftPressed(false);
|
||||
interaction.handleLayerPointerDown(
|
||||
createPointerEvent(event.currentTarget, {
|
||||
pointerId: 10,
|
||||
clientX: 220,
|
||||
clientY: 60,
|
||||
}),
|
||||
secondLayer,
|
||||
);
|
||||
interaction.finishDrag(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 10,
|
||||
clientX: 220,
|
||||
clientY: 60,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
直接点击第二层
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -508,7 +531,7 @@ function StageInteractionsHarness({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
onClick={() => {
|
||||
const frameElement = document.createElement('div');
|
||||
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||
if (!dialog) {
|
||||
@@ -581,6 +604,34 @@ function StageInteractionsHarness({
|
||||
>
|
||||
直接追加生成器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
const frameElement = document.createElement('div');
|
||||
const dialog = asCanvasGenerationDialog(generateDialog);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
interaction.setShiftPressed(false);
|
||||
interaction.handleGenerationFramePointerDown(
|
||||
createPointerEvent(frameElement, {
|
||||
pointerId: 11,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
}),
|
||||
dialog,
|
||||
);
|
||||
interaction.finishDrag(
|
||||
createPointerEvent(getViewportElement(), {
|
||||
pointerId: 11,
|
||||
clientX: 300,
|
||||
clientY: 200,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
直接点击生成器
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
@@ -674,6 +725,26 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
expect(screen.getByTestId('snap').textContent).toBe('-');
|
||||
});
|
||||
|
||||
it('collapses an unmoved layer pointer gesture to a single selection', () => {
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接选第一层' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加第二层' }).click();
|
||||
});
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
'second:first,second',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接点击第二层' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection').textContent).toBe('second:second');
|
||||
});
|
||||
|
||||
it('handles marquee, temporary hand mode, and generation frame dragging', () => {
|
||||
const activateCanvasGenerationDialog = vi.fn();
|
||||
render(
|
||||
@@ -764,6 +835,30 @@ describe('useImageCanvasStageInteractions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('collapses an unmoved generation frame pointer gesture to that frame', () => {
|
||||
render(<StageInteractionsHarness />);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接选第一层' }).click();
|
||||
});
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接追加生成器' }).click();
|
||||
});
|
||||
const generationSelectionId =
|
||||
screen.getByTestId('dialog-selection-id').textContent;
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`first:first,${generationSelectionId}`,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
screen.getByRole('button', { name: '直接点击生成器' }).click();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`-:${generationSelectionId}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes minimap click and drag movement', () => {
|
||||
const moveViewportFromMinimapPointer = vi.fn();
|
||||
const updateViewportFromMinimapDrag = vi.fn();
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
moveViewportFromPan,
|
||||
selectCanvasObjectsInsideMarquee,
|
||||
} from './ImageCanvasInteractionModel';
|
||||
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
|
||||
import {
|
||||
createCanvasMarqueeState,
|
||||
createGenerationFrameSelectionStart,
|
||||
@@ -42,6 +43,26 @@ import type {
|
||||
SnapGuide,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX = 3;
|
||||
|
||||
type PendingClickCollapse =
|
||||
| {
|
||||
kind: 'layer';
|
||||
pointerId: number;
|
||||
targetId: string;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
isMultiSelectGesture: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'generation-frame';
|
||||
pointerId: number;
|
||||
targetId: string;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
isMultiSelectGesture: boolean;
|
||||
};
|
||||
|
||||
type UseImageCanvasStageInteractionsOptions = {
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
activeTool: CanvasTool;
|
||||
@@ -97,6 +118,21 @@ function focusCanvasInteractionTarget(target: HTMLElement) {
|
||||
target.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function shouldCollapsePointerSelection(
|
||||
pending: PendingClickCollapse,
|
||||
pointer: { x: number; y: number },
|
||||
) {
|
||||
if (pending.isMultiSelectGesture) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
Math.hypot(
|
||||
pointer.x - pending.startClientX,
|
||||
pointer.y - pending.startClientY,
|
||||
) < CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX
|
||||
);
|
||||
}
|
||||
|
||||
export function useImageCanvasStageInteractions({
|
||||
canvasViewportRef,
|
||||
activeTool,
|
||||
@@ -137,6 +173,7 @@ export function useImageCanvasStageInteractions({
|
||||
onCloseImageContextMenu,
|
||||
}: UseImageCanvasStageInteractionsOptions) {
|
||||
const dragStateRef = useRef<DragState | null>(null);
|
||||
const pendingClickCollapseRef = useRef<PendingClickCollapse | null>(null);
|
||||
const isShiftPressedRef = useRef(false);
|
||||
const suppressNextLayerClickRef = useRef(false);
|
||||
const isViewportInteractionActiveRef = useRef(false);
|
||||
@@ -170,6 +207,7 @@ export function useImageCanvasStageInteractions({
|
||||
flushMinimapViewportDrag();
|
||||
}
|
||||
dragStateRef.current = null;
|
||||
pendingClickCollapseRef.current = null;
|
||||
setCanvasMarquee(null);
|
||||
setIsPanning(false);
|
||||
setSnapGuide(null);
|
||||
@@ -342,6 +380,14 @@ export function useImageCanvasStageInteractions({
|
||||
updateGenerateDialogForLayerPointerDown(currentDialog, layer.id),
|
||||
);
|
||||
dragStateRef.current = layerDragStart.dragState;
|
||||
pendingClickCollapseRef.current = {
|
||||
kind: 'layer',
|
||||
pointerId: getPointerId(event),
|
||||
targetId: layer.id,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
isMultiSelectGesture,
|
||||
};
|
||||
},
|
||||
[
|
||||
canvasViewportRef,
|
||||
@@ -465,6 +511,14 @@ export function useImageCanvasStageInteractions({
|
||||
setSelectedLayerId(frameDragStart.selectedLayerId);
|
||||
setSelectedLayerIds(frameDragStart.selectedLayerIds);
|
||||
dragStateRef.current = frameDragStart.dragState;
|
||||
pendingClickCollapseRef.current = {
|
||||
kind: 'generation-frame',
|
||||
pointerId: getPointerId(event),
|
||||
targetId: dialog.id,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
isMultiSelectGesture,
|
||||
};
|
||||
},
|
||||
[
|
||||
activateCanvasGenerationDialog,
|
||||
@@ -650,6 +704,26 @@ export function useImageCanvasStageInteractions({
|
||||
const pointer = getPointerClient(event);
|
||||
moveViewportFromMinimapPointer(pointer.x, pointer.y);
|
||||
}
|
||||
const pendingClickCollapse = pendingClickCollapseRef.current;
|
||||
const pointer = getPointerClient(event);
|
||||
if (
|
||||
pendingClickCollapse &&
|
||||
(pendingClickCollapse.pointerId < 0 ||
|
||||
pointerId < 0 ||
|
||||
pendingClickCollapse.pointerId === pointerId) &&
|
||||
shouldCollapsePointerSelection(pendingClickCollapse, pointer)
|
||||
) {
|
||||
if (pendingClickCollapse.kind === 'layer') {
|
||||
setSelectedLayerId(pendingClickCollapse.targetId);
|
||||
setSelectedLayerIds([pendingClickCollapse.targetId]);
|
||||
} else {
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([
|
||||
getCanvasGenerationSelectionId(pendingClickCollapse.targetId),
|
||||
]);
|
||||
}
|
||||
}
|
||||
pendingClickCollapseRef.current = null;
|
||||
if (dragState.kind === 'minimap') {
|
||||
flushMinimapViewportDrag();
|
||||
}
|
||||
@@ -670,6 +744,8 @@ export function useImageCanvasStageInteractions({
|
||||
finishViewportInteraction,
|
||||
flushMinimapViewportDrag,
|
||||
moveViewportFromMinimapPointer,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -15734,7 +15734,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="creation-home-stage flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
className="creation-home-stage creation-home-stage--landing flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
|
||||
>
|
||||
{platformHomeView}
|
||||
</motion.div>
|
||||
|
||||
@@ -6,6 +6,40 @@ import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformProfileRechargeModal } from './PlatformProfileRechargeModal';
|
||||
|
||||
function buildNormalMembership() {
|
||||
return {
|
||||
status: 'normal' as const,
|
||||
tier: 'normal' as const,
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
cycleStartedAt: null,
|
||||
cycleResetsAt: null,
|
||||
cycleGrantedPoints: 0,
|
||||
cycleRemainingPoints: 0,
|
||||
cyclePeriodDays: 30,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPointProduct() {
|
||||
return {
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points' as const,
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 30,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠30泥点',
|
||||
tier: 'normal' as const,
|
||||
membershipPeriodPoints: 0,
|
||||
membershipPeriodDays: 0,
|
||||
membershipQueueLimit: 0,
|
||||
membershipDiscountBps: 0,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn(async () => 'data:image/png;base64,wechat-native-qr'),
|
||||
@@ -21,27 +55,8 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 29,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60泥点',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 30,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充加赠',
|
||||
description: '首充加赠30泥点',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [buildPointProduct()],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
@@ -76,13 +91,7 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
@@ -114,13 +123,7 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 0,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
@@ -164,13 +167,7 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: {
|
||||
status: 'normal',
|
||||
tier: 'normal',
|
||||
startedAt: null,
|
||||
expiresAt: null,
|
||||
updatedAt: null,
|
||||
},
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
@@ -230,4 +227,54 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
);
|
||||
expect(onCloseNativePayment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows expired native payment state when qr countdown ends', async () => {
|
||||
const onConfirmNativePayment = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
walletBalance: 12,
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: [],
|
||||
membershipProducts: [],
|
||||
benefits: [],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={null}
|
||||
nativePayment={{
|
||||
orderId: 'rcg_native_expired',
|
||||
productTitle: '100泥点',
|
||||
amountCents: 1000,
|
||||
codeUrl: 'weixin://wxpay/bizpayurl?pr=expired',
|
||||
expiresAt: '2000-01-01T00:00:00Z',
|
||||
isConfirming: false,
|
||||
}}
|
||||
activeTab="points"
|
||||
onTabChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onConfirmNativePayment={onConfirmNativePayment}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const paymentDialog = screen.getByRole('dialog', {
|
||||
name: '微信扫码支付',
|
||||
});
|
||||
const confirmButton = within(paymentDialog).getByRole('button', {
|
||||
name: '已过期',
|
||||
});
|
||||
|
||||
expect(
|
||||
await within(paymentDialog).findByAltText('微信 Native 支付二维码'),
|
||||
).toBeTruthy();
|
||||
expect(within(paymentDialog).getAllByText('已过期')).toHaveLength(2);
|
||||
expect(confirmButton).toHaveProperty('disabled', true);
|
||||
expect(onConfirmNativePayment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,7 +229,11 @@ function PlatformProfileWechatNativePaymentModal({
|
||||
onClick={onConfirm}
|
||||
disabled={nativePayment.isConfirming || remaining.isExpired}
|
||||
>
|
||||
{nativePayment.isConfirming ? '确认中' : '我已支付'}
|
||||
{nativePayment.isConfirming
|
||||
? '确认中'
|
||||
: remaining.isExpired
|
||||
? '已过期'
|
||||
: '我已支付'}
|
||||
</PlatformActionButton>
|
||||
{nativePayment.confirmMessage ? (
|
||||
<div className="mt-3 text-xs font-semibold text-[var(--platform-text-soft)]">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user