Merge branch 'master' into feat/canvas-undo-protection
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user