Merge branch 'master' into feat/canvas-undo-protection

This commit is contained in:
2026-07-20 15:03:57 +08:00
38 changed files with 2343 additions and 544 deletions
@@ -527,6 +527,10 @@ export function ImageCanvasEditorView({
requestLogin: () => authUiRef.current?.openLoginModal(),
currentUser: authUi?.user ?? null,
});
const refreshEditorWalletState = useCallback(() => {
refreshEditorWalletBalance();
loadRechargeCenter();
}, [loadRechargeCenter, refreshEditorWalletBalance]);
const isAccountPaymentModalOpen =
isRewardCodeOpen ||
isRechargeOpen ||
@@ -1289,7 +1293,7 @@ export function ImageCanvasEditorView({
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
onWalletBalanceMayHaveChanged: refreshEditorWalletBalance,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
});
const handleEditorAgentConfirmSent = useCallback(() => {
generationSurface.refreshTaskList();
@@ -1308,7 +1312,7 @@ export function ImageCanvasEditorView({
if (warning) {
showGenerationWarning(warning);
}
refreshEditorWalletBalance();
refreshEditorWalletState();
void loadEditorProject(projectId)
.then(applyGeneratedProjectSnapshot)
.catch(() => undefined);
@@ -1316,7 +1320,7 @@ export function ImageCanvasEditorView({
[
applyGeneratedProjectSnapshot,
projectId,
refreshEditorWalletBalance,
refreshEditorWalletState,
showGenerationWarning,
],
);
@@ -1405,6 +1409,7 @@ export function ImageCanvasEditorView({
openCropExpandPanel,
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
extractUiDesignAssets,
pickCharacterSpecFromLayer,
pickGenerationReferenceFromLayer,
@@ -2176,6 +2181,7 @@ export function ImageCanvasEditorView({
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds,
uploadDropTarget,
contextMenu,
canvasClipboard,
@@ -42,6 +42,7 @@ function renderSelectedToolbar(
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
isSplittingIconSpritesheet: false,
onSplitIconSpritesheet: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
@@ -155,6 +156,33 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer);
});
it('renders a disabled loading state while splitting an icon spritesheet', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'icon-spritesheet',
});
const props = renderSelectedToolbar({
selectedLayer: layer,
isSplittingIconSpritesheet: true,
});
const splitButton = screen.getByRole('button', { name: '拆图中' });
expect(splitButton.getAttribute('title')).toBe('拆图中');
expect(splitButton.getAttribute('aria-busy')).toBe('true');
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
expect(splitButton.textContent).toContain('拆图中');
expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy();
expect(
splitButton.querySelector('.lucide-loader-circle.animate-spin'),
).toBeTruthy();
expect(splitButton.querySelector('.lucide-scissors')).toBeNull();
fireEvent.click(splitButton);
fireEvent.click(splitButton);
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
});
it('keeps only remodel and download for audio layers', () => {
const layer = createLayer({
title: '游戏音效',
@@ -293,6 +321,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
isSplittingIconSpritesheet={false}
onSplitIconSpritesheet={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
@@ -310,6 +339,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
isSplittingIconSpritesheet={false}
onSplitIconSpritesheet={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
@@ -2,6 +2,7 @@ import {
Crop,
Download,
ImageOff,
Loader2,
PersonStanding,
Scissors,
Sparkles,
@@ -21,6 +22,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
isSplittingIconSpritesheet: boolean;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void;
@@ -34,6 +36,7 @@ export function ImageCanvasSelectedLayerToolbarView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
isSplittingIconSpritesheet,
onSplitIconSpritesheet,
onExtractUiDesignAssets,
onOpenCharacterAnimationPanel,
@@ -120,12 +123,20 @@ export function ImageCanvasSelectedLayerToolbarView({
{selectedLayer.assetKind === 'icon-spritesheet' ? (
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label="拆分图集"
title="拆分图集"
icon={<Scissors className="h-4 w-4" />}
label={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
title={isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}
icon={
isSplittingIconSpritesheet ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Scissors className="h-4 w-4" />
)
}
disabled={isSplittingIconSpritesheet}
aria-busy={isSplittingIconSpritesheet}
onClick={() => onSplitIconSpritesheet(selectedLayer)}
>
<span></span>
<span>{isSplittingIconSpritesheet ? '拆图中' : '拆分图集'}</span>
</PlatformIconButton>
) : null}
{selectedLayer.assetKind === 'ui-design' ? (
@@ -345,9 +345,9 @@ describe('ImageCanvasStageInteractionModel', () => {
minimapScale: 0.4,
moved: false,
});
expect(
updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 }),
).toBe(minimapDrag);
expect(updateMinimapDragMovement(minimapDrag, { x: 121, y: 90 })).toBe(
minimapDrag,
);
expect(updateMinimapDragMovement(minimapDrag, { x: 123, y: 90 })).toEqual({
...minimapDrag,
moved: true,
@@ -385,7 +385,10 @@ describe('ImageCanvasStageInteractionModel', () => {
dialog: anotherDialog,
layers,
generationDialogs: [dialog, anotherDialog],
selectedLayerIds: ['layer-a', getCanvasGenerationSelectionId('dialog-1')],
selectedLayerIds: [
'layer-a',
getCanvasGenerationSelectionId('dialog-1'),
],
isMultiSelectGesture: true,
pointerId: 12,
pointer: { x: 360, y: 260 },
@@ -30,10 +30,13 @@ type PointerSource = {
};
};
type CanvasRectLike = {
left?: number;
top?: number;
} | null | undefined;
type CanvasRectLike =
| {
left?: number;
top?: number;
}
| null
| undefined;
const CANVAS_GENERATION_DIALOG_MODES = new Set([
'generate',
@@ -64,12 +67,22 @@ function hasCanvasGenerationDialogMode(
export function getPointerButton(event: PointerSource) {
const nativeButtons = Number(event.nativeEvent?.buttons);
if (Number.isFinite(nativeButtons) && (nativeButtons & 4) === 4) {
return 1;
if (Number.isFinite(nativeButtons)) {
if ((nativeButtons & 2) === 2) {
return 2;
}
if ((nativeButtons & 4) === 4) {
return 1;
}
}
const syntheticButtons = Number(event.buttons);
if (Number.isFinite(syntheticButtons) && (syntheticButtons & 4) === 4) {
return 1;
if (Number.isFinite(syntheticButtons)) {
if ((syntheticButtons & 2) === 2) {
return 2;
}
if ((syntheticButtons & 4) === 4) {
return 1;
}
}
const syntheticButton = Number(event.button);
if (Number.isFinite(syntheticButton)) {
@@ -200,9 +213,8 @@ export function createLayerDragStart({
isMultiSelectGesture,
});
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const dragLayerIds = selectedCanvasLayerIds.includes(layer.id)
? selectedCanvasLayerIds
: [layer.id];
@@ -300,9 +312,8 @@ export function createGenerationFrameSelectionStart({
selectedIds: selectedLayerIds,
isMultiSelectGesture,
});
const selectedGenerationDialogIds = getSelectedGenerationDialogIds(
nextSelectedLayerIds,
);
const selectedGenerationDialogIds =
getSelectedGenerationDialogIds(nextSelectedLayerIds);
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
const dragDialogIds = selectedGenerationDialogIds.includes(dialog.id)
? selectedGenerationDialogIds
@@ -42,6 +42,8 @@ import type {
import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExtractionOverlayView';
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
export type ImageCanvasStageViewProps = {
canvasViewportRef: RefObject<HTMLDivElement | null>;
specToolWrapRef: RefObject<HTMLSpanElement | null>;
@@ -75,6 +77,7 @@ export type ImageCanvasStageViewProps = {
quickEditSelectionSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
uploadDropTarget: 'canvas' | 'assets' | null;
contextMenu: CanvasContextMenuState | null;
canvasClipboard: CanvasClipboard | null;
@@ -228,6 +231,7 @@ export function ImageCanvasStageView({
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
uploadDropTarget,
contextMenu,
canvasClipboard,
@@ -369,6 +373,7 @@ export function ImageCanvasStageView({
cropExpandPanel={cropExpandPanel}
cropExpandSourceLayer={cropExpandSourceLayer}
generationComposerStyle={generationComposerStyle}
splittingIconSpritesheetLayerIds={splittingIconSpritesheetLayerIds}
onLayerPointerDown={onLayerPointerDown}
onLayerClick={onLayerClick}
onSelectLayer={onSelectLayer}
@@ -390,6 +395,10 @@ export function ImageCanvasStageView({
: selectedLayer
}
selectedToolbarStyle={selectedToolbarStyle}
isSplittingIconSpritesheet={Boolean(
selectedLayer &&
splittingIconSpritesheetLayerIds.has(selectedLayer.id),
)}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
onOpenCropExpandPanel={onOpenCropExpandPanel}
@@ -120,7 +120,7 @@ describe('ImageCanvasTopbarView', () => {
await user.click(screen.getByRole('button', { name: '充值' }));
expect(props.onRecharge).toHaveBeenCalledTimes(1);
expect(props.onRequestWalletDetails).not.toHaveBeenCalled();
expect(props.onRequestWalletDetails).toHaveBeenCalledTimes(1);
});
it('shows the current user avatar beside the mud point balance', () => {
@@ -138,6 +138,22 @@ describe('ImageCanvasWorldView', () => {
expect(screen.queryByRole('status', { name: '' })).toBeNull();
});
it('shows the atlas splitting treatment on the source layer', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
renderWorldView({
layers: [layer],
splittingIconSpritesheetLayerIds: new Set([layer.id]),
});
const sourceLayer = screen.getByRole('button', {
name: `选择${layer.title}`,
});
expect(sourceLayer.className).toContain(
'image-canvas-editor__layer--generating',
);
expect(within(sourceLayer).getByRole('status').textContent).toBe('拆图中');
});
it('keeps the image layer frame visible while the image is loading', () => {
useResolvedAssetReadUrlMock.mockImplementation(() => ({
resolvedUrl: '',
@@ -250,6 +250,7 @@ export type ImageCanvasWorldViewProps = {
cropExpandPanel: CropExpandPanelState | null;
cropExpandSourceLayer: CanvasLayer | null;
generationComposerStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLElement>,
layer: CanvasLayer,
@@ -285,6 +286,8 @@ export type ImageCanvasWorldViewProps = {
) => void;
};
const EMPTY_LAYER_ID_SET: ReadonlySet<string> = new Set();
const CROP_EXPAND_HANDLES: Array<{
value: CropExpandResizeHandle;
label: string;
@@ -916,6 +919,7 @@ export function ImageCanvasWorldView({
cropExpandPanel,
cropExpandSourceLayer,
generationComposerStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
onLayerPointerDown,
onLayerClick,
onSelectLayer,
@@ -1007,10 +1011,13 @@ export function ImageCanvasWorldView({
layer.flipX || layer.flipY
? `scale(${layer.flipX ? -1 : 1}, ${layer.flipY ? -1 : 1})`
: undefined;
const layerGeneratingLabel =
generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
const layerGeneratingLabel = splittingIconSpritesheetLayerIds.has(
layer.id,
)
? '拆图中'
: generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
? '修改中'
: null;
const isMediaContainer = isCanvasMediaContainerLayer(layer);
@@ -535,6 +535,11 @@ function GenerationWorkflowHarness({
>
</button>
<output aria-label="拆图状态">
{workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id)
? '拆图中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
@@ -1913,6 +1918,65 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
});
it('exposes a splitting state while the manual atlas request is pending', async () => {
let resolveSplit:
| ((value: { iconImageSrcs: never[]; project: unknown }) => void)
| undefined;
splitEditorIconSpritesheetMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSplit = resolve;
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={vi.fn()}
initialLayers={[
createLayer({
assetKind: 'icon-spritesheet',
originalWidth: 1024,
originalHeight: 1024,
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
fireEvent.click(screen.getByRole('button', { name: '拆分图集' }));
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
'拆图中',
);
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledTimes(1);
resolveSplit?.({
iconImageSrcs: [],
project: {
projectId: 'project-1',
title: '未命名画布',
canvas: {
canvasId: 'project-1:canvas:default',
projectId: 'project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-07-18T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-18T00:00:00.000Z',
},
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '拆图状态' }).textContent,
).toBe('空闲');
});
});
it('queues background removal for private character images', async () => {
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
'data:image/png;base64,resolved-character',
@@ -677,6 +677,8 @@ export function useImageCanvasGenerationWorkflow({
}, []);
const previousTaskCountRef = useRef(canvasGenerationDialogs.length);
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
@@ -1735,6 +1737,11 @@ export function useImageCanvasGenerationWorkflow({
return;
}
splittingIconSpritesheetLayerIdsRef.current.add(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(sourceLayer.id);
return nextLayerIds;
});
closeGenerationTransientState();
setImageContextMenu(null);
setMetadataLayer(null);
@@ -1771,6 +1778,14 @@ export function useImageCanvasGenerationWorkflow({
);
} finally {
splittingIconSpritesheetLayerIdsRef.current.delete(sourceLayer.id);
setSplittingIconSpritesheetLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(sourceLayer.id)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(sourceLayer.id);
return nextLayerIds;
});
}
},
[
@@ -2544,6 +2559,7 @@ export function useImageCanvasGenerationWorkflow({
startCropExpandFrameResize,
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
taskListRefreshKey,
refreshTaskList,
isTaskSidebarOpen,
@@ -2646,6 +2662,7 @@ export function useImageCanvasGenerationWorkflow({
quickEditSourceLayer,
removeSelectedLayerBackground,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
submitCropExpand,
submitIconSpritesheetGeneration,
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { act, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import {
type PointerEvent as ReactPointerEvent,
useRef,
@@ -325,7 +325,10 @@ function StageInteractionsHarness({
</span>
<span data-testid="layers">
{layers
.map((layer) => `${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`)
.map(
(layer) =>
`${layer.id}:${layer.x.toFixed(1)},${layer.y.toFixed(1)}`,
)
.join('|')}
</span>
<span data-testid="viewport-state">
@@ -339,7 +342,9 @@ function StageInteractionsHarness({
<span data-testid="panning">{String(interaction.isPanning)}</span>
<span data-testid="tool">{interaction.effectiveTool}</span>
<span data-testid="snap">
{interaction.snapGuide?.vertical ?? interaction.snapGuide?.horizontal ?? '-'}
{interaction.snapGuide?.vertical ??
interaction.snapGuide?.horizontal ??
'-'}
</span>
<span data-testid="dialog-position">
{generateDialog?.placeholder
@@ -353,9 +358,7 @@ function StageInteractionsHarness({
{String(generateDialog?.composerOpen ?? false)}
</span>
<span data-testid="clear-count">{clearCount}</span>
<span data-testid="image-menu-close-count">
{imageMenuCloseCount}
</span>
<span data-testid="image-menu-close-count">{imageMenuCloseCount}</span>
<button
type="button"
onClick={() => {
@@ -718,6 +721,86 @@ function StageInteractionsHarness({
>
</button>
<button
type="button"
onClick={(event) => {
interaction.handleLayerPointerDown(
createPointerEvent(event.currentTarget, {
pointerId: 20,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
firstLayer,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
setActiveTool('hand');
interaction.handleLayerPointerDown(
createPointerEvent(event.currentTarget, {
pointerId: 21,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
firstLayer,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 22,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
dialog,
);
}}
>
</button>
<button
type="button"
onClick={(event) => {
setActiveTool('hand');
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 23,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
dialog,
);
}}
>
</button>
</div>
);
}
@@ -837,7 +920,9 @@ describe('useImageCanvasStageInteractions', () => {
act(() => {
screen.getByRole('button', { name: '直接移动平移' }).click();
});
expect(screen.getByTestId('viewport-state').textContent).toBe('30.0,25.0,1');
expect(screen.getByTestId('viewport-state').textContent).toBe(
'30.0,25.0,1',
);
act(() => {
screen.getByRole('button', { name: '清理交互' }).click();
});
@@ -859,9 +944,7 @@ describe('useImageCanvasStageInteractions', () => {
render(<StageInteractionsHarness />);
act(() => {
screen
.getByRole('button', { name: '直接贴近图层拖生成占位' })
.click();
screen.getByRole('button', { name: '直接贴近图层拖生成占位' }).click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
@@ -903,8 +986,9 @@ describe('useImageCanvasStageInteractions', () => {
act(() => {
screen.getByRole('button', { name: '直接追加生成器' }).click();
});
const generationSelectionId =
screen.getByTestId('dialog-selection-id').textContent;
const generationSelectionId = screen.getByTestId(
'dialog-selection-id',
).textContent;
expect(screen.getByTestId('selection').textContent).toBe(
`first:first,${generationSelectionId}`,
);
@@ -979,4 +1063,172 @@ describe('useImageCanvasStageInteractions', () => {
expect(flushMinimapViewportDrag).toHaveBeenCalledTimes(1);
expect(onViewportInteractionEnd).toHaveBeenCalledTimes(2);
});
it('allows middle-button dragging and rejects right-button dragging in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
const viewport = screen.getByTestId('viewport');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 1,
buttons: 4,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('true');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerup', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 1,
buttons: 0,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '切抓手' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
fireEvent(
viewport,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 100,
clientY: 100,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
});
it('rejects right-click panning on layers in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '右键选第一层' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('selection').textContent).toBe('-:');
act(() => {
screen.getByRole('button', { name: '右键抓手模式选第一层' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('tool').textContent).toBe('hand');
expect(screen.getByTestId('selection').textContent).toBe('-:');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
const layer = screen.getByTestId('layer-first');
fireEvent(
layer,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 40,
clientY: 40,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('selection').textContent).toBe('-:');
});
it('rejects right-click panning on generation frames in select, hand, and Space modes', () => {
render(<StageInteractionsHarness />);
act(() => {
screen.getByRole('button', { name: '右键拖生成占位' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
act(() => {
screen.getByRole('button', { name: '右键抓手模式拖生成占位' }).click();
});
expect(screen.getByTestId('panning').textContent).toBe('false');
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
screen.getByRole('button', { name: '松开空格' }).click();
});
act(() => {
screen.getByRole('button', { name: '按住空格' }).click();
});
expect(screen.getByTestId('tool').textContent).toBe('hand');
act(() => {
const frame = screen.getByTestId('generation-frame');
fireEvent(
frame,
new MouseEvent('pointerdown', {
bubbles: true,
clientX: 300,
clientY: 200,
button: 2,
buttons: 2,
}),
);
});
expect(screen.getByTestId('panning').textContent).toBe('false');
});
});
@@ -94,9 +94,7 @@ type UseImageCanvasStageInteractionsOptions = {
pickUiDesignSpecFromLayer: (layer: CanvasLayer) => void;
pickPublicationReferenceFromLayer: (layer: CanvasLayer) => void;
openLayerGenerationDialog?: (layer: CanvasLayer) => boolean;
activateCanvasGenerationDialog: (
dialog: CanvasGenerationDialogState,
) => void;
activateCanvasGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: (
@@ -235,6 +233,11 @@ export function useImageCanvasStageInteractions({
}
}, [finishViewportInteraction, flushMinimapViewportDrag]);
const rejectRightButtonInteraction = useCallback(() => {
// Preserve the pointer event so the matching contextmenu event can still open.
clearActiveInteraction();
}, [clearActiveInteraction]);
const setShiftPressed = useCallback((pressed: boolean) => {
isShiftPressedRef.current = pressed;
}, []);
@@ -258,12 +261,17 @@ export function useImageCanvasStageInteractions({
const handleCanvasPointerDown = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
const button = getPointerButton(event);
if (button !== 0 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
return;
}
const target = event.target as HTMLElement;
@@ -288,18 +296,29 @@ export function useImageCanvasStageInteractions({
}
clearCanvasFocus();
},
[canvasViewportRef, clearCanvasFocus, effectiveTool, startPan],
[
canvasViewportRef,
clearCanvasFocus,
effectiveTool,
rejectRightButtonInteraction,
startPan,
],
);
const handleLayerPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>, layer: CanvasLayer) => {
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
event.stopPropagation();
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
event.stopPropagation();
return;
}
@@ -440,6 +459,7 @@ export function useImageCanvasStageInteractions({
pickIconSpecFromLayer,
pickPublicationReferenceFromLayer,
pickUiDesignSpecFromLayer,
rejectRightButtonInteraction,
selectedLayerIds,
setGenerateDialog,
setSelectedLayerId,
@@ -512,12 +532,18 @@ export function useImageCanvasStageInteractions({
return;
}
const button = getPointerButton(event);
if (button === 1 || effectiveTool === 'hand') {
if (button === 2) {
rejectRightButtonInteraction();
return;
}
if (button === 1 || (button === 0 && effectiveTool === 'hand')) {
event.stopPropagation();
startPan(event);
return;
}
if (button !== 0) {
event.preventDefault();
event.stopPropagation();
return;
}
@@ -570,6 +596,7 @@ export function useImageCanvasStageInteractions({
effectiveTool,
getCanvasHistorySnapshot,
layers,
rejectRightButtonInteraction,
selectedLayerIds,
setSelectedLayerId,
setSelectedLayerIds,
@@ -597,6 +624,9 @@ export function useImageCanvasStageInteractions({
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.buttons & 2) !== 0 && (event.buttons & (1 | 4)) === 0) {
return;
}
if (canvasMarquee && canvasMarquee.pointerId === event.pointerId) {
event.preventDefault();
const rect = canvasViewportRef.current?.getBoundingClientRect();
@@ -385,6 +385,7 @@ export function usePlatformProfileCenterController({
useCopyFeedback();
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
const rechargeCenterReadRevisionRef = useRef(0);
// 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。
useEffect(() => {
@@ -426,18 +427,40 @@ export function usePlatformProfileCenterController({
loadWalletLedger();
}, [loadWalletLedger]);
const applyRechargeCenter = useCallback(
(center: ProfileRechargeCenterResponse) => {
rechargeCenterReadRevisionRef.current += 1;
setIsLoadingRechargeCenter(false);
setRechargeError(null);
setRechargeCenter(center);
},
[],
);
const loadRechargeCenter = useCallback(() => {
const revision = ++rechargeCenterReadRevisionRef.current;
setRechargeError(null);
setIsLoadingRechargeCenter(true);
void getRpgProfileRechargeCenter()
.then(setRechargeCenter)
.then((center) => {
if (revision === rechargeCenterReadRevisionRef.current) {
setRechargeCenter(center);
}
})
.catch((error: unknown) => {
if (revision !== rechargeCenterReadRevisionRef.current) {
return;
}
setRechargeCenter(null);
setRechargeError(
error instanceof Error ? error.message : '读取泥点购买信息失败',
);
})
.finally(() => setIsLoadingRechargeCenter(false));
.finally(() => {
if (revision === rechargeCenterReadRevisionRef.current) {
setIsLoadingRechargeCenter(false);
}
});
}, []);
const refreshRechargeState = useCallback(() => {
@@ -482,7 +505,7 @@ export function usePlatformProfileCenterController({
.then((response) => {
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
@@ -525,7 +548,7 @@ export function usePlatformProfileCenterController({
clearWechatPayResultHash();
return true;
}, [onRechargeSuccess, refreshRechargeState]);
}, [applyRechargeCenter, onRechargeSuccess, refreshRechargeState]);
const pollWechatPayResultFromHash = useCallback(
() => handleWechatPayResult(),
@@ -549,7 +572,7 @@ export function usePlatformProfileCenterController({
.then((response) => {
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
pendingWechatRechargeOrderIdRef.current = null;
confirmingWechatRechargeOrderIdRef.current = null;
setWechatRechargeOrderConfirmationState(null);
@@ -569,7 +592,7 @@ export function usePlatformProfileCenterController({
});
});
return true;
}, [nativeWechatPayment, onRechargeSuccess]);
}, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]);
const openRechargeModal = useCallback(() => {
if (!currentUser) {
@@ -625,7 +648,7 @@ export function usePlatformProfileCenterController({
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
const paymentHandled = await requestHostPayment({
payload: response.wechatMiniProgramPayParams,
orderId: response.order.orderId,
@@ -637,7 +660,7 @@ export function usePlatformProfileCenterController({
}
if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'pending',
title: '正在打开微信支付',
@@ -657,7 +680,7 @@ export function usePlatformProfileCenterController({
confirmResponse.order,
);
const isPaid = result.kind === 'success';
setRechargeCenter(confirmResponse.center);
applyRechargeCenter(confirmResponse.center);
setRechargePaymentResult(result);
if (result.kind !== 'pending') {
pendingWechatRechargeOrderIdRef.current = null;
@@ -681,7 +704,7 @@ export function usePlatformProfileCenterController({
throw new Error('微信 H5 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
setRechargePaymentResult({
kind: 'pending',
title: '正在打开微信支付',
@@ -698,7 +721,7 @@ export function usePlatformProfileCenterController({
throw new Error('微信 Native 支付链接生成失败');
}
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
setNativeWechatPayment({
...wechatNativePayment,
codeUrl,
@@ -764,7 +787,7 @@ export function usePlatformProfileCenterController({
setSubmittingRechargeProductId(null);
});
},
[onRechargeSuccess, submittingRechargeProductId],
[applyRechargeCenter, onRechargeSuccess, submittingRechargeProductId],
);
const confirmNativeWechatPayment = useCallback(() => {
@@ -787,7 +810,7 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
if (result.kind !== 'pending') {
setNativeWechatPayment(null);
pendingWechatRechargeOrderIdRef.current = null;
@@ -819,7 +842,7 @@ export function usePlatformProfileCenterController({
);
})
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
}, [applyRechargeCenter, nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const orderId = nativeWechatPayment?.orderId;
@@ -845,7 +868,7 @@ export function usePlatformProfileCenterController({
}
const result = buildRechargePaymentResultForOrder(response.order);
setRechargeCenter(response.center);
applyRechargeCenter(response.center);
if (result.kind === 'pending') {
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
continue;
@@ -882,6 +905,7 @@ export function usePlatformProfileCenterController({
}, [
nativeWechatPayment?.expiresAt,
nativeWechatPayment?.orderId,
applyRechargeCenter,
onRechargeSuccess,
]);
@@ -387,6 +387,29 @@ describe('ProjectGalleryView', () => {
});
});
it('keeps the project rename modal inside the active platform theme', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(
<AuthUiContext.Provider
value={createAuthValue({ platformTheme: 'dark' })}
>
<ProjectGalleryView onOpenProject={vi.fn()} />
</AuthUiContext.Provider>,
);
await screen.findByText('角色设定板');
await user.click(
screen.getByRole('button', { name: '打开项目角色设定板菜单' }),
);
await user.click(screen.getByRole('menuitem', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '重命名' });
expect(dialog.closest('.platform-theme--dark')).toBeTruthy();
expect(dialog.className).toContain('platform-remap-surface');
});
it('supports batch selection actions from the bottom toolbar', async () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
deleteEditorProjectMock.mockResolvedValue('deleted');
@@ -30,7 +30,7 @@ import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { UnifiedModal } from '../common/UnifiedModal';
import { PlatformToolModalShell } from '../common/PlatformToolModalShell';
import {
LOCAL_PROJECT_COVER_CACHE_ASSET_KIND,
resolveProjectCoverResource,
@@ -436,7 +436,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
<section className="project-gallery__grid">{projectCards}</section>
)}
<UnifiedModal
<PlatformToolModalShell
open={Boolean(renameDraft)}
title="重命名"
size="sm"
@@ -482,7 +482,7 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
/>
</form>
) : null}
</UnifiedModal>
</PlatformToolModalShell>
{isSelectionMode ? (
<PlatformBatchActionToolbar>
@@ -4465,6 +4465,98 @@ test('logged in mobile recommend page exposes the shared wallet details and rech
expect(within(rechargeDialog).queryByText('会员月卡')).toBeNull();
});
test('the latest recharge center read wins when wallet and recharge overlap', async () => {
const user = userEvent.setup();
mockNarrowMobileLayout();
let resolveWalletRead!: (
center: ProfileRechargeCenterResponse,
) => void;
let resolveRechargeRead!: (
center: ProfileRechargeCenterResponse,
) => void;
const walletReadPromise = new Promise<ProfileRechargeCenterResponse>(
(resolve) => {
resolveWalletRead = resolve;
},
);
const rechargeReadPromise = new Promise<ProfileRechargeCenterResponse>(
(resolve) => {
resolveRechargeRead = resolve;
},
);
mockGetRpgProfileRechargeCenter
.mockReturnValueOnce(walletReadPromise)
.mockReturnValueOnce(rechargeReadPromise);
const { container } = render(
<ProfileHomeViewHarness
activeTab="home"
profileDashboardOverrides={{ walletBalance: 207 }}
/>,
);
const walletLayer = container.querySelector(
'.platform-mobile-recommend-wallet-entry',
);
expect(walletLayer).toBeTruthy();
await user.click(
within(walletLayer as HTMLElement).getByRole('button', {
name: '泥点 207',
}),
);
const rechargeButtons = within(walletLayer as HTMLElement).getAllByRole(
'button',
{ name: '充值' },
);
await user.click(rechargeButtons[0]!);
expect(mockGetRpgProfileRechargeCenter).toHaveBeenCalledTimes(2);
resolveRechargeRead({
walletBalance: 207,
mudPointBalance: {
totalPoints: 207,
permanentPoints: 187,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 20,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-20T16:00:00Z',
},
membership: buildNormalMembership(),
pointProducts: [buildPointProduct()],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
});
expect(await screen.findByText('购买更多泥点')).toBeTruthy();
expect(screen.getByText('当前余额 207 泥点')).toBeTruthy();
resolveWalletRead({
walletBalance: 0,
mudPointBalance: {
totalPoints: 0,
permanentPoints: 0,
limitedPoints: 0,
limitedExpiresAt: null,
dailyFreePoints: 0,
dailyFreeResetPoints: 20,
dailyFreeResetsAt: '2026-07-20T16:00:00Z',
},
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
});
await act(async () => undefined);
expect(screen.getByText('当前余额 207 泥点')).toBeTruthy();
});
test('mobile discover search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();