Merge branch 'master' into editor-agent-refactored

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-07-10 20:52:03 +08:00
181 changed files with 18161 additions and 2599 deletions
@@ -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