Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library

This commit is contained in:
2026-06-22 19:18:31 +08:00
61 changed files with 2091 additions and 304 deletions
@@ -611,4 +611,79 @@ describe('ImageCanvasEditorModel', () => {
horizontal: anchorLayer.y,
});
});
it('snaps moving layers to equal horizontal and vertical spacing', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
resourceId: 'resource-moving',
title: '移动图',
src: 'data:image/png;base64,moving',
x: 0,
y: 0,
width: 80,
height: 80,
originalWidth: 80,
originalHeight: 80,
zIndex: 1,
sourceType: 'uploaded',
};
const firstLayer: CanvasLayer = {
...movingLayer,
id: 'first',
resourceId: 'resource-first',
x: 100,
y: 160,
zIndex: 2,
};
const secondLayer: CanvasLayer = {
...movingLayer,
id: 'second',
resourceId: 'resource-second',
x: 260,
y: 160,
zIndex: 3,
};
const topLayer: CanvasLayer = {
...movingLayer,
id: 'top',
resourceId: 'resource-top',
x: 520,
y: 120,
zIndex: 4,
};
const bottomLayer: CanvasLayer = {
...movingLayer,
id: 'bottom',
resourceId: 'resource-bottom',
x: 520,
y: 300,
zIndex: 5,
};
const horizontalSnap = resolveSnappedLayerPosition(
movingLayer,
417,
160,
[movingLayer, firstLayer, secondLayer],
1,
);
expect(horizontalSnap.x).toBe(420);
expect(horizontalSnap.guide).toEqual({
vertical: 460,
horizontal: 160,
});
const verticalSnap = resolveSnappedLayerPosition(
movingLayer,
520,
207,
[movingLayer, topLayer, bottomLayer],
1,
);
expect(verticalSnap.y).toBe(210);
expect(verticalSnap.guide).toEqual({
vertical: 520,
horizontal: 250,
});
});
});
@@ -9,6 +9,7 @@ import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
CanvasLayer,
CanvasSnapItem,
CanvasMediaType,
CanvasViewport,
CharacterReferenceImage,
@@ -35,6 +36,7 @@ export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
export const TOOLBAR_HALF_WIDTH = 132;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const SNAP_THRESHOLD_SCREEN_PX = 18;
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
export const FIT_VIEW_PADDING = 10;
export const MINIMAP_SIZE = { width: 132, height: 84 };
export const MINIMAP_PADDING = 8;
@@ -1120,43 +1122,78 @@ export function resolveSnappedLayerPosition(
proposedY: number,
layers: CanvasLayer[],
scale: number,
) {
return resolveSnappedItemPosition(
movingLayer,
proposedX,
proposedY,
layers,
scale,
);
}
export function resolveSnappedItemPosition(
movingItem: CanvasSnapItem,
proposedX: number,
proposedY: number,
items: CanvasSnapItem[],
scale: number,
) {
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
const visibleItems = items.filter(
(item) => item.id !== movingItem.id && !item.hidden,
);
const verticalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.x,
layer.x + layer.width / 2,
layer.x + layer.width,
]),
...visibleItems.flatMap((item) => [
item.x,
item.x + item.width / 2,
item.x + item.width,
]),
];
const horizontalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.y,
layer.y + layer.height / 2,
layer.y + layer.height,
]),
...visibleItems.flatMap((item) => [
item.y,
item.y + item.height / 2,
item.y + item.height,
]),
];
const xSnap = findNearestSnap(
const xAlignmentSnap = findNearestSnap(
proposedX,
[0, movingLayer.width / 2, movingLayer.width],
[0, movingItem.width / 2, movingItem.width],
verticalTargets,
threshold,
);
const ySnap = findNearestSnap(
const yAlignmentSnap = findNearestSnap(
proposedY,
[0, movingLayer.height / 2, movingLayer.height],
[0, movingItem.height / 2, movingItem.height],
horizontalTargets,
threshold,
);
const xDistributionSnap = findNearestEqualSpacingSnap({
axis: 'x',
proposedStart: proposedX,
proposedCrossStart: proposedY,
movingSize: movingItem.width,
movingCrossSize: movingItem.height,
items: visibleItems,
threshold,
});
const yDistributionSnap = findNearestEqualSpacingSnap({
axis: 'y',
proposedStart: proposedY,
proposedCrossStart: proposedX,
movingSize: movingItem.height,
movingCrossSize: movingItem.width,
items: visibleItems,
threshold,
});
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
return {
x: xSnap ? xSnap.position : proposedX,
@@ -1171,6 +1208,177 @@ export function resolveSnappedLayerPosition(
};
}
function chooseNearestSnap(
first: SnapCandidate | null,
second: SnapCandidate | null,
) {
if (!first) {
return second;
}
if (!second) {
return first;
}
return second.distance < first.distance ? second : first;
}
function findNearestEqualSpacingSnap({
axis,
proposedStart,
proposedCrossStart,
movingSize,
movingCrossSize,
items,
threshold,
}: {
axis: 'x' | 'y';
proposedStart: number;
proposedCrossStart: number;
movingSize: number;
movingCrossSize: number;
items: CanvasSnapItem[];
threshold: number;
}): SnapCandidate | null {
const orderedItems = [...items].sort(
(firstItem, secondItem) =>
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
);
let nearest: SnapCandidate | null = null;
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
const firstItem = orderedItems[firstIndex];
if (!firstItem) {
continue;
}
for (
let secondIndex = firstIndex + 1;
secondIndex < orderedItems.length;
secondIndex += 1
) {
const secondItem = orderedItems[secondIndex];
if (!secondItem) {
continue;
}
if (
!snapItemsOverlapOnCrossAxis(
proposedCrossStart,
movingCrossSize,
firstItem,
axis,
) ||
!snapItemsOverlapOnCrossAxis(
proposedCrossStart,
movingCrossSize,
secondItem,
axis,
)
) {
continue;
}
const firstStart = getSnapItemStart(firstItem, axis);
const firstEnd = getSnapItemEnd(firstItem, axis);
const secondStart = getSnapItemStart(secondItem, axis);
const secondEnd = getSnapItemEnd(secondItem, axis);
const pairGap = secondStart - firstEnd;
if (pairGap < 0) {
continue;
}
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstStart - pairGap - movingSize,
proposedStart,
movingSize,
threshold,
}),
);
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: secondEnd + pairGap,
proposedStart,
movingSize,
threshold,
}),
);
const betweenGap = (pairGap - movingSize) / 2;
if (betweenGap >= 0) {
nearest = chooseNearestSnap(
nearest,
createDistributionSnapCandidate({
position: firstEnd + betweenGap,
proposedStart,
movingSize,
threshold,
}),
);
}
}
}
return nearest;
}
function createDistributionSnapCandidate({
position,
proposedStart,
movingSize,
threshold,
}: {
position: number;
proposedStart: number;
movingSize: number;
threshold: number;
}): SnapCandidate | null {
const distance = Math.abs(position - proposedStart);
if (distance > threshold) {
return null;
}
return {
position,
guide: position + movingSize / 2,
distance,
};
}
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.x : item.y;
}
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.width : item.height;
}
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
}
function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.y : item.x;
}
function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') {
return axis === 'x' ? item.height : item.width;
}
function snapItemsOverlapOnCrossAxis(
proposedCrossStart: number,
movingCrossSize: number,
item: CanvasSnapItem,
axis: 'x' | 'y',
) {
const movingCrossEnd = proposedCrossStart + movingCrossSize;
const itemCrossStart = getSnapItemCrossStart(item, axis);
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
return (
proposedCrossStart <=
itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
);
}
export function findNearestSnap(
origin: number,
offsets: number[],
@@ -106,6 +106,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
submitProjectRename: vi.fn(),
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
};
}
@@ -359,6 +359,15 @@ export type SnapCandidate = {
distance: number;
};
export type CanvasSnapItem = {
id: string;
x: number;
y: number;
width: number;
height: number;
hidden?: boolean;
};
export type AssetMarqueeState = {
pointerId: number;
startX: number;
@@ -22,6 +22,7 @@ import {
resolveContextMenuPosition,
} from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import type {
AssetPointerDragState,
CanvasAssetKind,
@@ -36,6 +37,11 @@ import type {
import { getEditorUploadAccept } from './ImageCanvasFileModel';
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
import {
getCanvasGenerationSelectionId,
getSelectedGenerationDialogIds,
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
import {
@@ -122,6 +128,7 @@ export function ImageCanvasEditorView() {
const [uploadDropTarget, setUploadDropTarget] = useState<
'canvas' | 'assets' | null
>(null);
const [isShortcutDialogOpen, setIsShortcutDialogOpen] = useState(false);
const captureViewportHistory = useCallback(() => {
captureCanvasHistoryRef.current();
}, []);
@@ -730,8 +737,11 @@ export function ImageCanvasEditorView() {
canvasClipboard,
pasteCanvasClipboard,
copyContextLayers,
copySelectedLayers,
duplicateContextLayers,
duplicateSelectedLayers,
moveContextLayers,
moveSelectedLayers,
groupContextLayers,
ungroupContextLayers,
toggleContextLayerVisibility,
@@ -883,6 +893,117 @@ export function ImageCanvasEditorView() {
},
[captureCanvasHistory, persistUpdatedLayerResource],
);
const selectAllCanvasObjects = useCallback(() => {
const layerSelectionIds = layersRef.current
.filter((layer) => !layer.hidden)
.map((layer) => layer.id);
const layerIds = new Set(layersRef.current.map((layer) => layer.id));
const generationSelectionIds = canvasGenerationDialogsRef.current
.filter((dialog) => {
if (!dialog.placeholder) {
return false;
}
return !(
dialog.generatedLayerId && layerIds.has(dialog.generatedLayerId)
);
})
.map((dialog) => getCanvasGenerationSelectionId(dialog.id));
const nextSelectionIds = [...layerSelectionIds, ...generationSelectionIds];
setSelectedLayerIds(nextSelectionIds);
setSelectedLayerId(layerSelectionIds[0] ?? null);
setImageContextMenu(null);
setContextMenu(null);
setGenerateDialog((currentDialog) =>
isCanvasGenerationComposerVisible(currentDialog)
? {
...currentDialog,
composerOpen: false,
}
: currentDialog,
);
}, [setGenerateDialog]);
const clearCanvasSelection = useCallback(() => {
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
}, []);
const nudgeSelectedCanvasObjects = useCallback(
(delta: { x: number; y: number }) => {
const selectionIds = selectedLayerIdsRef.current.length
? selectedLayerIdsRef.current
: selectedLayerIdRef.current
? [selectedLayerIdRef.current]
: [];
const targetLayerIds = getSelectedLayerIds(selectionIds);
const targetDialogIds = getSelectedGenerationDialogIds(selectionIds);
if (!targetLayerIds.length && !targetDialogIds.length) {
return;
}
captureCanvasHistory();
if (targetLayerIds.length) {
setLayers((currentLayers) =>
currentLayers.map((layer) =>
targetLayerIds.includes(layer.id)
? {
...layer,
x: layer.x + delta.x,
y: layer.y + delta.y,
}
: layer,
),
);
}
targetDialogIds.forEach((dialogId) => {
updateCanvasGenerationDialogById(dialogId, (dialog) =>
dialog.placeholder
? {
...dialog,
placeholder: {
...dialog.placeholder,
x: dialog.placeholder.x + delta.x,
y: dialog.placeholder.y + delta.y,
},
}
: dialog,
);
});
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory, updateCanvasGenerationDialogById],
);
const exportCanvasAssetsFromShortcut = useCallback(() => {
if (!layersRef.current.some((layer) => layer.src.trim().length > 0)) {
return;
}
void exportCanvasAssets();
}, [exportCanvasAssets]);
const zoomViewportBy = useCallback(
(scaleFactor: number) => {
updateScaleFromCenter(viewportRef.current.scale * scaleFactor);
},
[updateScaleFromCenter],
);
const switchTool = useCallback(
(tool: CanvasTool) => {
clearActiveInteraction();
if (tool === 'upload') {
requestUpload('asset', { addToCanvas: true });
return;
}
if (switchGenerationTool(tool)) {
return;
}
setActiveTool(tool);
},
[
clearActiveInteraction,
requestUpload,
setActiveTool,
switchGenerationTool,
],
);
useImageCanvasKeyboardShortcuts({
generateDialogRef,
@@ -892,8 +1013,23 @@ export function ImageCanvasEditorView() {
undoCanvasChange,
deleteLayerById: deleteLayerByIdFromShortcut,
deleteSelectedCanvasObjects: deleteSelectedLayer,
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers: copySelectedLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers: duplicateSelectedLayers,
moveSelectedCanvasLayers: moveSelectedLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool,
switchTool,
updateScaleFromCenter,
zoomViewportBy,
fitLayers,
toggleSidebarPanel,
toggleMinimap,
startProjectRename,
exportCanvasAssets: exportCanvasAssetsFromShortcut,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
@@ -969,17 +1105,6 @@ export function ImageCanvasEditorView() {
deleteLayerByIdRef.current = deleteLayerById;
const switchTool = (tool: CanvasTool) => {
clearActiveInteraction();
if (tool === 'upload') {
requestUpload('asset', { addToCanvas: true });
return;
}
if (switchGenerationTool(tool)) {
return;
}
setActiveTool(tool);
};
const assetDragPreview = assetPointerDrag?.active
? {
x: assetPointerDrag.currentClientX,
@@ -1062,6 +1187,7 @@ export function ImageCanvasEditorView() {
submitProjectRename,
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts: () => setIsShortcutDialogOpen(true),
};
const stageProps = {
canvasViewportRef,
@@ -1202,6 +1328,10 @@ export function ImageCanvasEditorView() {
>
删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。
</PlatformDangerConfirmDialog>
<ImageCanvasShortcutDialogView
open={isShortcutDialogOpen}
onClose={() => setIsShortcutDialogOpen(false)}
/>
</>
);
}
@@ -263,15 +263,19 @@ describe('ImageCanvasInteractionModel', () => {
expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({
x: 200,
y: 90,
y: 100,
});
expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({
x: 330,
y: 90,
y: 100,
});
expect(result?.snapGuide).toEqual({
vertical: 300,
horizontal: 90,
horizontal: 100,
});
expect(result?.snapOffset).toEqual({
x: 0,
y: 10,
});
const generationDialogs = [
@@ -292,6 +296,7 @@ describe('ImageCanvasInteractionModel', () => {
moveGenerationFramesFromDrag({
generationDialogs,
pointer: { x: 140, y: 170 },
snapOffset: { x: 6, y: -4 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
@@ -309,15 +314,20 @@ describe('ImageCanvasInteractionModel', () => {
startLayers: [{ id: 'moving', x: 90, y: 90 }],
startScale: 2,
},
}).map((dialog) => [dialog.id, dialog.placeholder?.x, dialog.placeholder?.y]),
}).generationDialogs.map((dialog) => [
dialog.id,
dialog.placeholder?.x,
dialog.placeholder?.y,
]),
).toEqual([
['dialog-1', 120, 105],
['dialog-2', 420, 105],
['dialog-1', 126, 101],
['dialog-2', 426, 101],
]);
expect(
moveDragSelectedLayers({
layers,
pointer: { x: 140, y: 170 },
snapOffset: { x: 6, y: -4 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-1',
@@ -333,7 +343,54 @@ describe('ImageCanvasInteractionModel', () => {
startScale: 2,
},
}).find((layer) => layer.id === 'moving'),
).toMatchObject({ x: 110, y: 75 });
).toMatchObject({ x: 116, y: 71 });
});
it('snaps generation frames to nearby layer guides while dragging', () => {
const [anchorLayer] = [
createLayer({ id: 'anchor', x: 420, y: 300, width: 120, height: 100 }),
];
const [movingDialog] = [
createGenerationDialog({
id: 'dialog-moving',
placeholder: {
x: 100,
y: 120,
width: 120,
height: 100,
originalWidth: 120,
originalHeight: 100,
},
}),
];
const result = moveGenerationFramesFromDrag({
layers: [anchorLayer],
generationDialogs: [movingDialog],
pointer: { x: 414, y: 292 },
dragState: {
kind: 'generation-frame',
dialogId: 'dialog-moving',
dialogIds: ['dialog-moving'],
layerIds: [],
pointerId: 1,
startClientX: 100,
startClientY: 120,
startFrameX: 100,
startFrameY: 120,
startFrames: [{ id: 'dialog-moving', x: 100, y: 120 }],
startLayers: [],
startScale: 1,
},
});
expect(result.generationDialogs[0]?.placeholder).toMatchObject({
x: 420,
y: 300,
});
expect(result.snapGuide).toEqual({
vertical: 420,
horizontal: 300,
});
});
it('builds minimap projection and moves the viewport from minimap interactions', () => {
@@ -17,7 +17,7 @@ import {
MINIMAP_SIZE,
clamp,
getLayerBounds,
resolveSnappedLayerPosition,
resolveSnappedItemPosition,
} from './ImageCanvasEditorModel';
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
@@ -40,7 +40,14 @@ export type CanvasRect = {
export type CanvasLayerMoveResult = {
layers: CanvasLayer[];
snapGuide: ReturnType<typeof resolveSnappedLayerPosition>['guide'];
snapGuide: ReturnType<typeof resolveSnappedItemPosition>['guide'];
snapOffset: CanvasPoint;
};
export type CanvasGenerationFrameMoveResult = {
generationDialogs: CanvasGenerationDialogState[];
snapGuide: ReturnType<typeof resolveSnappedItemPosition>['guide'];
snapOffset: CanvasPoint;
};
export type StageMinimapModel = {
@@ -269,6 +276,47 @@ function intersectsScreenRect({
);
}
function getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds = [],
excludedGenerationDialogIds = [],
}: {
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
excludedLayerIds?: string[];
excludedGenerationDialogIds?: string[];
}) {
const layerSnapItems = layers
.filter((layer) => !excludedLayerIds.includes(layer.id))
.map((layer) => ({
id: layer.id,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
hidden: layer.hidden,
}));
const generationSnapItems = generationDialogs.flatMap((dialog) => {
if (
!dialog.placeholder ||
excludedGenerationDialogIds.includes(dialog.id)
) {
return [];
}
return [
{
id: getCanvasGenerationSelectionId(dialog.id),
x: dialog.placeholder.x,
y: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
},
];
});
return [...layerSnapItems, ...generationSnapItems];
}
export function selectLayersInsideMarquee({
marquee,
currentPoint,
@@ -338,10 +386,12 @@ export function selectCanvasObjectsInsideMarquee({
export function moveLayersFromDrag({
dragState,
layers,
generationDialogs = [],
pointer,
}: {
dragState: Extract<DragState, { kind: 'layer' }>;
layers: CanvasLayer[];
generationDialogs?: CanvasGenerationDialogState[];
pointer: CanvasPoint;
}): CanvasLayerMoveResult | null {
const movingLayer = layers.find((layer) => layer.id === dragState.layerId);
@@ -352,16 +402,26 @@ export function moveLayersFromDrag({
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
const proposedX = dragState.startLayerX + deltaX;
const proposedY = dragState.startLayerY + deltaY;
const snapped = resolveSnappedLayerPosition(
const snapped = resolveSnappedItemPosition(
movingLayer,
proposedX,
proposedY,
layers,
getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds: dragState.layerIds,
excludedGenerationDialogIds: dragState.generationDialogIds ?? [],
}),
dragState.startScale,
);
const snapOffset = {
x: snapped.x - proposedX,
y: snapped.y - proposedY,
};
return {
snapGuide: snapped.guide,
snapOffset,
layers: layers.map((layer) =>
dragState.layerIds.includes(layer.id)
? (() => {
@@ -392,12 +452,16 @@ export function moveLayersFromDrag({
export function moveGenerationFramesFromDrag({
dragState,
generationDialogs,
layers = [],
snapOffset,
pointer,
}: {
dragState: Extract<DragState, { kind: 'layer' | 'generation-frame' }>;
generationDialogs: CanvasGenerationDialogState[];
layers?: CanvasLayer[];
snapOffset?: CanvasPoint | null;
pointer: CanvasPoint;
}) {
}): CanvasGenerationFrameMoveResult {
const deltaX = (pointer.x - dragState.startClientX) / dragState.startScale;
const deltaY = (pointer.y - dragState.startClientY) / dragState.startScale;
const targetDialogIds =
@@ -405,13 +469,64 @@ export function moveGenerationFramesFromDrag({
? dragState.dialogIds
: (dragState.generationDialogIds ?? []);
if (!targetDialogIds.length) {
return generationDialogs;
return {
generationDialogs,
snapGuide: null,
snapOffset: { x: 0, y: 0 },
};
}
const startFrames =
dragState.kind === 'generation-frame'
? dragState.startFrames
: (dragState.startGenerationFrames ?? []);
return generationDialogs.map((dialog) => {
const movingDialogId =
dragState.kind === 'generation-frame'
? dragState.dialogId
: targetDialogIds[0];
const movingDialog = generationDialogs.find(
(dialog) => dialog.id === movingDialogId,
);
const movingPlaceholder = movingDialog?.placeholder;
const movingStartFrame = startFrames.find(
(frame) => frame.id === movingDialogId,
);
const proposedX = (movingStartFrame?.x ?? 0) + deltaX;
const proposedY = (movingStartFrame?.y ?? 0) + deltaY;
const snapped =
snapOffset || dragState.kind === 'layer'
? {
x: proposedX + (snapOffset?.x ?? 0),
y: proposedY + (snapOffset?.y ?? 0),
guide: null,
}
: movingDialog && movingPlaceholder && movingStartFrame
? resolveSnappedItemPosition(
{
id: getCanvasGenerationSelectionId(movingDialog.id),
x: movingPlaceholder.x,
y: movingPlaceholder.y,
width: movingPlaceholder.width,
height: movingPlaceholder.height,
},
proposedX,
proposedY,
getCanvasSnapItems({
layers,
generationDialogs,
excludedLayerIds: dragState.layerIds,
excludedGenerationDialogIds: targetDialogIds,
}),
dragState.startScale,
)
: { x: proposedX, y: proposedY, guide: null };
const appliedSnapOffset = {
x: snapped.x - proposedX,
y: snapped.y - proposedY,
};
return {
snapGuide: snapped.guide,
snapOffset: appliedSnapOffset,
generationDialogs: generationDialogs.map((dialog) => {
if (!targetDialogIds.includes(dialog.id) || !dialog.placeholder) {
return dialog;
}
@@ -423,20 +538,23 @@ export function moveGenerationFramesFromDrag({
...dialog,
placeholder: {
...dialog.placeholder,
x: startFrame.x + deltaX,
y: startFrame.y + deltaY,
x: startFrame.x + deltaX + appliedSnapOffset.x,
y: startFrame.y + deltaY + appliedSnapOffset.y,
},
};
});
}),
};
}
export function moveDragSelectedLayers({
dragState,
layers,
snapOffset = { x: 0, y: 0 },
pointer,
}: {
dragState: Extract<DragState, { kind: 'generation-frame' }>;
layers: CanvasLayer[];
snapOffset?: CanvasPoint;
pointer: CanvasPoint;
}) {
if (!dragState.layerIds.length) {
@@ -454,8 +572,8 @@ export function moveDragSelectedLayers({
}
return {
...layer,
x: startLayer.x + deltaX,
y: startLayer.y + deltaY,
x: startLayer.x + deltaX + snapOffset.x,
y: startLayer.y + deltaY + snapOffset.y,
};
});
}
@@ -0,0 +1,21 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
describe('ImageCanvasShortcutDialogView', () => {
it('renders the Windows canvas shortcut table', () => {
render(<ImageCanvasShortcutDialogView open onClose={vi.fn()} />);
const dialog = screen.getByRole('dialog', { name: '画布快捷键' });
expect(within(dialog).getByRole('heading', { name: '编辑' })).toBeTruthy();
expect(within(dialog).getByText('全选画布元素')).toBeTruthy();
expect(within(dialog).getAllByText('Ctrl').length).toBeGreaterThan(0);
expect(within(dialog).getByText('A')).toBeTruthy();
expect(within(dialog).getByText('生成视频')).toBeTruthy();
expect(within(dialog).getAllByText('新增').length).toBeGreaterThan(0);
});
});
@@ -0,0 +1,65 @@
import { UnifiedModal } from '../common/UnifiedModal';
import { IMAGE_CANVAS_SHORTCUT_SECTIONS } from './ImageCanvasShortcutModel';
type ImageCanvasShortcutDialogViewProps = {
open: boolean;
onClose: () => void;
};
export function ImageCanvasShortcutDialogView({
open,
onClose,
}: ImageCanvasShortcutDialogViewProps) {
return (
<UnifiedModal
open={open}
title="画布快捷键"
size="lg"
onClose={onClose}
panelClassName="image-canvas-editor__shortcut-modal"
bodyClassName="image-canvas-editor__shortcut-modal-body"
>
<div className="image-canvas-editor__shortcut-sections">
{IMAGE_CANVAS_SHORTCUT_SECTIONS.map((section) => (
<section
key={section.title}
className="image-canvas-editor__shortcut-section"
aria-labelledby={`image-canvas-shortcut-${section.title}`}
>
<h3 id={`image-canvas-shortcut-${section.title}`}>
{section.title}
</h3>
<dl className="image-canvas-editor__shortcut-list">
{section.items.map((item) => (
<div
key={`${section.title}-${item.action}`}
className="image-canvas-editor__shortcut-row"
>
<dt>
{item.action}
{item.status === 'new' ? <span>新增</span> : null}
</dt>
<dd>
{item.keys.map((key, index) => (
<span key={`${item.action}-${key}-${index}`}>
{index > 0 ? (
<span
className="image-canvas-editor__shortcut-plus"
aria-hidden="true"
>
+
</span>
) : null}
<kbd>{key}</kbd>
</span>
))}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
</UnifiedModal>
);
}
@@ -0,0 +1,80 @@
export type ImageCanvasShortcutStatus = 'ready' | 'new';
export type ImageCanvasShortcutItem = {
action: string;
keys: string[];
status: ImageCanvasShortcutStatus;
};
export type ImageCanvasShortcutSection = {
title: string;
items: ImageCanvasShortcutItem[];
};
export const IMAGE_CANVAS_SHORTCUT_SECTIONS: ImageCanvasShortcutSection[] = [
{
title: '编辑',
items: [
{ action: '撤销', keys: ['Ctrl', 'Z'], status: 'ready' },
{ action: '重做', keys: ['Ctrl', 'Shift', 'Z'], status: 'ready' },
{ action: '全选画布元素', keys: ['Ctrl', 'A'], status: 'new' },
{ action: '复制选中图层', keys: ['Ctrl', 'C'], status: 'new' },
{ action: '粘贴图层', keys: ['Ctrl', 'V'], status: 'new' },
{ action: '剪切选中图层', keys: ['Ctrl', 'X'], status: 'new' },
{ action: '复制一份选中图层', keys: ['Ctrl', 'D'], status: 'new' },
{ action: '删除选中元素', keys: ['Delete / Backspace'], status: 'ready' },
{ action: '取消选择或关闭弹层', keys: ['Esc'], status: 'ready' },
],
},
{
title: '视图',
items: [
{ action: '纵向滚动画布', keys: ['鼠标滚轮'], status: 'ready' },
{ action: '以鼠标位置缩放', keys: ['Ctrl', '鼠标滚轮'], status: 'ready' },
{ action: '显示全部元素', keys: ['Ctrl', '0'], status: 'new' },
{ action: '缩放至 100%', keys: ['Ctrl', '1'], status: 'new' },
{ action: '放大', keys: ['Ctrl', '+'], status: 'new' },
{ action: '缩小', keys: ['Ctrl', '-'], status: 'new' },
{ action: '临时抓手拖动画布', keys: ['Space', '拖拽'], status: 'ready' },
],
},
{
title: '工具',
items: [
{ action: '选择工具', keys: ['V'], status: 'new' },
{ action: '抓手工具', keys: ['H'], status: 'new' },
{ action: '上传素材', keys: ['U'], status: 'new' },
{ action: '生成图片', keys: ['G'], status: 'new' },
{ action: '生成视频', keys: ['Shift', 'V'], status: 'new' },
{ action: '生成音乐', keys: ['M'], status: 'new' },
],
},
{
title: '图层',
items: [
{ action: '多选元素', keys: ['Shift', '点击 / 拖拽'], status: 'ready' },
{ action: '微移选中图层', keys: ['方向键'], status: 'new' },
{ action: '大步移动选中图层', keys: ['Shift', '方向键'], status: 'new' },
{ action: '上移图层层级', keys: ['Ctrl', ']'], status: 'new' },
{ action: '下移图层层级', keys: ['Ctrl', '['], status: 'new' },
{ action: '置顶图层', keys: ['Ctrl', 'Shift', ']'], status: 'new' },
{ action: '置底图层', keys: ['Ctrl', 'Shift', '['], status: 'new' },
{ action: '激活生成占位', keys: ['双击生成占位'], status: 'ready' },
{
action: '选择音频 / 视频图层',
keys: ['Enter / Space'],
status: 'ready',
},
],
},
{
title: '面板与项目',
items: [
{ action: '打开素材面板', keys: ['Alt', '1'], status: 'new' },
{ action: '打开图层面板', keys: ['Alt', '2'], status: 'new' },
{ action: '切换小地图', keys: ['Alt', 'M'], status: 'new' },
{ action: '下载画布素材', keys: ['Ctrl', 'Shift', 'S'], status: 'new' },
{ action: '重命名项目', keys: ['F2'], status: 'new' },
],
},
];
@@ -46,6 +46,7 @@ function renderTopbar(
submitProjectRename: vi.fn(),
resetProjectRenameError: vi.fn(),
exportCanvasAssets: vi.fn(),
onOpenShortcuts: vi.fn(),
...overrides,
};
@@ -66,6 +67,10 @@ describe('ImageCanvasTopbarView', () => {
fireEvent.click(screen.getByRole('button', { name: '编辑项目名称' }));
expect(props.startProjectRename).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: '查看画布快捷键' }));
expect(props.onOpenShortcuts).toHaveBeenCalledTimes(1);
});
it('submits, resets, and cancels project rename edits', () => {
@@ -108,6 +113,7 @@ describe('ImageCanvasTopbarView', () => {
submitProjectRename={vi.fn()}
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
/>,
);
@@ -136,6 +142,7 @@ describe('ImageCanvasTopbarView', () => {
submitProjectRename={vi.fn()}
resetProjectRenameError={vi.fn()}
exportCanvasAssets={exportCanvasAssets}
onOpenShortcuts={vi.fn()}
/>,
);
@@ -1,4 +1,11 @@
import { Check, ChevronLeft, Download, Pencil, X } from 'lucide-react';
import {
Check,
ChevronLeft,
Download,
Keyboard,
Pencil,
X,
} from 'lucide-react';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
@@ -22,6 +29,7 @@ export type ImageCanvasTopbarViewProps = {
submitProjectRename: (projectId: string | null) => void;
resetProjectRenameError: () => void;
exportCanvasAssets: () => void | Promise<void>;
onOpenShortcuts: () => void;
};
export function ImageCanvasTopbarView({
@@ -40,6 +48,7 @@ export function ImageCanvasTopbarView({
submitProjectRename,
resetProjectRenameError,
exportCanvasAssets,
onOpenShortcuts,
}: ImageCanvasTopbarViewProps) {
const hasExportableLayer = layers.some(
(layer) => layer.src.trim().length > 0,
@@ -126,6 +135,12 @@ export function ImageCanvasTopbarView({
<span>画布</span>
</div>
<div className="image-canvas-editor__topbar-actions">
<EditorIconButton
label="查看画布快捷键"
title="快捷键"
icon={Keyboard}
onClick={onOpenShortcuts}
/>
<EditorIconButton
label="下载画布素材"
title="下载画布素材"
@@ -1034,6 +1034,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
submissionPlan.input.kind === 'publication-material'
? await normalizePublicationReferenceImages(submissionPlan.input)
: submissionPlan.input;
const resultTitle =
submissionPlan.result.title ??
`生成图片 ${layerCounterRef.current + 1}`;
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const generated = await generateEditorImage({
@@ -1042,15 +1045,12 @@ export function useImageCanvasGenerationSubmissionWorkflow({
assetKind: submissionPlan.result.assetKind,
generationInputs: submissionPlan.result.generationInputs,
assetFolderId,
assetLabel: submissionPlan.result.title,
assetLabel: resultTitle,
...(canvasDialog && projectId && canvasCompletionPlaceholder
? {
canvasCompletion: {
dialogId: canvasDialog.id,
title:
submissionPlan.result.title ??
imageGenerationInput.prompt ??
'AI 生成图片',
title: resultTitle,
placeholder: canvasCompletionPlaceholder,
},
}
@@ -1080,9 +1080,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({
return;
}
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
frame: canvasCompletionPlaceholder,
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
title: resultTitle,
dialogId: canvasDialog?.id,
generationInputs: submissionPlan.result.generationInputs,
});
@@ -62,7 +62,22 @@ function KeyboardShortcutsHarness({
redoCanvasChange = vi.fn(),
deleteLayerById = vi.fn(),
deleteSelectedCanvasObjects = vi.fn(),
selectAllCanvasObjects = vi.fn(),
clearCanvasSelection = vi.fn(),
copySelectedCanvasLayers = vi.fn(),
pasteCanvasClipboard = vi.fn(),
duplicateSelectedCanvasLayers = vi.fn(),
moveSelectedCanvasLayers = vi.fn(),
nudgeSelectedCanvasObjects = vi.fn(),
requestRemoveCanvasGenerationDialog = vi.fn(),
switchTool,
updateScaleFromCenter = vi.fn(),
zoomViewportBy = vi.fn(),
fitLayers = vi.fn(),
toggleSidebarPanel = vi.fn(),
toggleMinimap = vi.fn(),
startProjectRename = vi.fn(),
exportCanvasAssets = vi.fn(),
closeEditorChromePanels = vi.fn(),
}: {
selectedLayerId?: string | null;
@@ -74,9 +89,24 @@ function KeyboardShortcutsHarness({
redoCanvasChange?: () => void;
deleteLayerById?: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
selectAllCanvasObjects?: () => void;
clearCanvasSelection?: () => void;
copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
pasteCanvasClipboard?: () => void;
duplicateSelectedCanvasLayers?: () => void;
moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
switchTool?: (tool: CanvasTool) => void;
updateScaleFromCenter?: (nextScale: number) => void;
zoomViewportBy?: (scaleFactor: number) => void;
fitLayers?: () => void;
toggleSidebarPanel?: (panel: 'assets' | 'layers') => void;
toggleMinimap?: () => void;
startProjectRename?: () => void;
exportCanvasAssets?: () => void;
closeEditorChromePanels?: () => void;
}) {
const [activeTool, setActiveTool] = useState<CanvasTool>(initialTool);
@@ -128,8 +158,23 @@ function KeyboardShortcutsHarness({
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool: (tool) => setActiveTool(tool),
switchTool: switchTool ?? ((tool) => setActiveTool(tool)),
updateScaleFromCenter,
zoomViewportBy,
fitLayers,
toggleSidebarPanel,
toggleMinimap,
startProjectRename,
exportCanvasAssets,
setGenerateDialog: (updater) =>
setGenerateDialogState((currentDialog) =>
applyGenerateDialogUpdater(updater, currentDialog),
@@ -238,6 +283,168 @@ describe('useImageCanvasKeyboardShortcuts', () => {
});
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
act(() => {
fireEvent.keyDown(window, { key: 'z', code: 'KeyZ', metaKey: true });
});
expect(undoCanvasChange).toHaveBeenCalledTimes(1);
});
it('routes Windows edit shortcuts to selected layer commands', () => {
const selectAllCanvasObjects = vi.fn();
const copySelectedCanvasLayers = vi.fn();
const pasteCanvasClipboard = vi.fn();
const duplicateSelectedCanvasLayers = vi.fn();
const exportCanvasAssets = vi.fn();
render(
<KeyboardShortcutsHarness
selectAllCanvasObjects={selectAllCanvasObjects}
copySelectedCanvasLayers={copySelectedCanvasLayers}
pasteCanvasClipboard={pasteCanvasClipboard}
duplicateSelectedCanvasLayers={duplicateSelectedCanvasLayers}
exportCanvasAssets={exportCanvasAssets}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'a', code: 'KeyA', ctrlKey: true });
fireEvent.keyDown(window, { key: 'c', code: 'KeyC', ctrlKey: true });
fireEvent.keyDown(window, { key: 'v', code: 'KeyV', ctrlKey: true });
fireEvent.keyDown(window, { key: 'x', code: 'KeyX', ctrlKey: true });
fireEvent.keyDown(window, { key: 'd', code: 'KeyD', ctrlKey: true });
fireEvent.keyDown(window, {
key: 'S',
code: 'KeyS',
ctrlKey: true,
shiftKey: true,
});
});
expect(selectAllCanvasObjects).toHaveBeenCalledTimes(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(1);
expect(copySelectedCanvasLayers).toHaveBeenNthCalledWith(2, { cut: true });
expect(pasteCanvasClipboard).toHaveBeenCalledTimes(1);
expect(duplicateSelectedCanvasLayers).toHaveBeenCalledTimes(1);
expect(exportCanvasAssets).toHaveBeenCalledTimes(1);
});
it('routes Windows view and layer ordering shortcuts', () => {
const fitLayers = vi.fn();
const updateScaleFromCenter = vi.fn();
const zoomViewportBy = vi.fn();
const moveSelectedCanvasLayers = vi.fn();
render(
<KeyboardShortcutsHarness
fitLayers={fitLayers}
updateScaleFromCenter={updateScaleFromCenter}
zoomViewportBy={zoomViewportBy}
moveSelectedCanvasLayers={moveSelectedCanvasLayers}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: '0', code: 'Digit0', ctrlKey: true });
fireEvent.keyDown(window, { key: '1', code: 'Digit1', ctrlKey: true });
fireEvent.keyDown(window, { key: '=', code: 'Equal', ctrlKey: true });
fireEvent.keyDown(window, { key: '-', code: 'Minus', ctrlKey: true });
fireEvent.keyDown(window, {
key: ']',
code: 'BracketRight',
ctrlKey: true,
});
fireEvent.keyDown(window, {
key: '}',
code: 'BracketRight',
ctrlKey: true,
shiftKey: true,
});
fireEvent.keyDown(window, {
key: '[',
code: 'BracketLeft',
ctrlKey: true,
});
fireEvent.keyDown(window, {
key: '{',
code: 'BracketLeft',
ctrlKey: true,
shiftKey: true,
});
});
expect(fitLayers).toHaveBeenCalledTimes(1);
expect(updateScaleFromCenter).toHaveBeenCalledWith(1);
expect(zoomViewportBy).toHaveBeenCalledWith(1.16);
expect(zoomViewportBy).toHaveBeenCalledWith(0.86);
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(1, 'up');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(2, 'top');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(3, 'down');
expect(moveSelectedCanvasLayers).toHaveBeenNthCalledWith(4, 'bottom');
});
it('routes tool, panel and project shortcuts', () => {
const switchTool = vi.fn();
const toggleSidebarPanel = vi.fn();
const toggleMinimap = vi.fn();
const startProjectRename = vi.fn();
render(
<KeyboardShortcutsHarness
switchTool={switchTool}
toggleSidebarPanel={toggleSidebarPanel}
toggleMinimap={toggleMinimap}
startProjectRename={startProjectRename}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'v', code: 'KeyV' });
fireEvent.keyDown(window, { key: 'h', code: 'KeyH' });
fireEvent.keyDown(window, { key: 'u', code: 'KeyU' });
fireEvent.keyDown(window, { key: 'g', code: 'KeyG' });
fireEvent.keyDown(window, { key: 'V', code: 'KeyV', shiftKey: true });
fireEvent.keyDown(window, { key: 'm', code: 'KeyM' });
fireEvent.keyDown(window, { key: '1', code: 'Digit1', altKey: true });
fireEvent.keyDown(window, { key: '2', code: 'Digit2', altKey: true });
fireEvent.keyDown(window, { key: 'm', code: 'KeyM', altKey: true });
fireEvent.keyDown(window, { key: 'F2', code: 'F2' });
});
expect(switchTool).toHaveBeenNthCalledWith(1, 'select');
expect(switchTool).toHaveBeenNthCalledWith(2, 'hand');
expect(switchTool).toHaveBeenNthCalledWith(3, 'upload');
expect(switchTool).toHaveBeenNthCalledWith(4, 'generate');
expect(switchTool).toHaveBeenNthCalledWith(5, 'video');
expect(switchTool).toHaveBeenNthCalledWith(6, 'music');
expect(toggleSidebarPanel).toHaveBeenNthCalledWith(1, 'assets');
expect(toggleSidebarPanel).toHaveBeenNthCalledWith(2, 'layers');
expect(toggleMinimap).toHaveBeenCalledTimes(1);
expect(startProjectRename).toHaveBeenCalledTimes(1);
});
it('nudges selected canvas objects with arrow keys', () => {
const nudgeSelectedCanvasObjects = vi.fn();
render(
<KeyboardShortcutsHarness
nudgeSelectedCanvasObjects={nudgeSelectedCanvasObjects}
/>,
);
act(() => {
fireEvent.keyDown(window, { key: 'ArrowUp', code: 'ArrowUp' });
fireEvent.keyDown(window, {
key: 'ArrowRight',
code: 'ArrowRight',
shiftKey: true,
});
});
expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(1, {
x: 0,
y: -1,
});
expect(nudgeSelectedCanvasObjects).toHaveBeenNthCalledWith(2, {
x: 10,
y: 0,
});
});
it('deletes the selected layer with Backspace outside editable inputs', () => {
@@ -2,8 +2,10 @@ import { type RefObject, useEffect } from 'react';
import type {
CanvasGenerationDialogState,
CanvasTool,
GenerateDialogState,
QuickEditPanelState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
type UseImageCanvasKeyboardShortcutsOptions = {
@@ -14,10 +16,25 @@ type UseImageCanvasKeyboardShortcutsOptions = {
undoCanvasChange: () => void;
deleteLayerById: (layerId: string | null) => void;
deleteSelectedCanvasObjects?: () => void;
selectAllCanvasObjects?: () => void;
clearCanvasSelection?: () => void;
copySelectedCanvasLayers?: (options?: { cut?: boolean }) => void;
pasteCanvasClipboard?: () => void;
duplicateSelectedCanvasLayers?: () => void;
moveSelectedCanvasLayers?: (mode: 'up' | 'down' | 'top' | 'bottom') => void;
nudgeSelectedCanvasObjects?: (delta: { x: number; y: number }) => void;
requestRemoveCanvasGenerationDialog?: (
dialog: CanvasGenerationDialogState,
) => void;
setActiveTool: (tool: 'select') => void;
switchTool?: (tool: CanvasTool) => void;
updateScaleFromCenter?: (nextScale: number) => void;
zoomViewportBy?: (scaleFactor: number) => void;
fitLayers?: () => void;
toggleSidebarPanel?: (panel: SidebarPanel) => void;
toggleMinimap?: () => void;
startProjectRename?: () => void;
exportCanvasAssets?: () => void;
setGenerateDialog: (
updater:
| GenerateDialogState
@@ -30,9 +47,7 @@ type UseImageCanvasKeyboardShortcutsOptions = {
updater:
| QuickEditPanelState
| null
| ((
panel: QuickEditPanelState | null,
) => QuickEditPanelState | null),
| ((panel: QuickEditPanelState | null) => QuickEditPanelState | null),
) => void;
closeEditorChromePanels: () => void;
setIsSpecMenuOpen: (open: boolean) => void;
@@ -93,6 +108,48 @@ function isCanvasGenerationDialogState(
return Boolean(dialog?.id && isCanvasGenerationPlaceholderDialog(dialog));
}
function isCtrlShortcut(event: KeyboardEvent) {
return event.ctrlKey && !event.altKey && !event.metaKey;
}
function isKey(event: KeyboardEvent, key: string, code?: string) {
return event.key.toLowerCase() === key || event.code === code;
}
function isPlusKey(event: KeyboardEvent) {
return (
event.key === '+' ||
event.key === '=' ||
event.code === 'Equal' ||
event.code === 'NumpadAdd'
);
}
function isMinusKey(event: KeyboardEvent) {
return (
event.key === '-' ||
event.code === 'Minus' ||
event.code === 'NumpadSubtract'
);
}
function getArrowDelta(event: KeyboardEvent) {
const step = event.shiftKey ? 10 : 1;
if (event.key === 'ArrowUp') {
return { x: 0, y: -step };
}
if (event.key === 'ArrowDown') {
return { x: 0, y: step };
}
if (event.key === 'ArrowLeft') {
return { x: -step, y: 0 };
}
if (event.key === 'ArrowRight') {
return { x: step, y: 0 };
}
return null;
}
export function useImageCanvasKeyboardShortcuts({
generateDialogRef,
selectedLayerIdRef,
@@ -101,8 +158,23 @@ export function useImageCanvasKeyboardShortcuts({
undoCanvasChange,
deleteLayerById,
deleteSelectedCanvasObjects,
selectAllCanvasObjects,
clearCanvasSelection,
copySelectedCanvasLayers,
pasteCanvasClipboard,
duplicateSelectedCanvasLayers,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
requestRemoveCanvasGenerationDialog,
setActiveTool,
switchTool,
updateScaleFromCenter,
zoomViewportBy,
fitLayers,
toggleSidebarPanel,
toggleMinimap,
startProjectRename,
exportCanvasAssets,
setGenerateDialog,
setImageContextMenu,
setContextMenu,
@@ -177,7 +249,7 @@ export function useImageCanvasKeyboardShortcuts({
const handleKeyDown = (event: KeyboardEvent) => {
if (
(event.ctrlKey || event.metaKey) &&
isCtrlShortcut(event) &&
event.code === 'KeyZ' &&
!isEditableTarget(event)
) {
@@ -189,6 +261,138 @@ export function useImageCanvasKeyboardShortcuts({
}
return;
}
if (isCtrlShortcut(event) && !isEditableTarget(event)) {
if (event.code === 'KeyA') {
event.preventDefault();
selectAllCanvasObjects?.();
return;
}
if (event.code === 'KeyC') {
event.preventDefault();
copySelectedCanvasLayers?.();
return;
}
if (event.code === 'KeyV') {
event.preventDefault();
pasteCanvasClipboard?.();
return;
}
if (event.code === 'KeyX') {
event.preventDefault();
copySelectedCanvasLayers?.({ cut: true });
return;
}
if (event.code === 'KeyD') {
event.preventDefault();
duplicateSelectedCanvasLayers?.();
return;
}
if (event.code === 'KeyS' && event.shiftKey) {
event.preventDefault();
exportCanvasAssets?.();
return;
}
if (event.key === '0' || event.code === 'Digit0') {
event.preventDefault();
fitLayers?.();
return;
}
if (event.key === '1' || event.code === 'Digit1') {
event.preventDefault();
updateScaleFromCenter?.(1);
return;
}
if (isPlusKey(event)) {
event.preventDefault();
zoomViewportBy?.(1.16);
return;
}
if (isMinusKey(event)) {
event.preventDefault();
zoomViewportBy?.(0.86);
return;
}
if (event.code === 'BracketRight') {
event.preventDefault();
moveSelectedCanvasLayers?.(event.shiftKey ? 'top' : 'up');
return;
}
if (event.code === 'BracketLeft') {
event.preventDefault();
moveSelectedCanvasLayers?.(event.shiftKey ? 'bottom' : 'down');
return;
}
}
if (
event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!isEditableTarget(event)
) {
if (event.code === 'Digit1') {
event.preventDefault();
toggleSidebarPanel?.('assets');
return;
}
if (event.code === 'Digit2') {
event.preventDefault();
toggleSidebarPanel?.('layers');
return;
}
if (event.code === 'KeyM') {
event.preventDefault();
toggleMinimap?.();
return;
}
}
if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isEditableTarget(event)
) {
const arrowDelta = getArrowDelta(event);
if (arrowDelta) {
event.preventDefault();
nudgeSelectedCanvasObjects?.(arrowDelta);
return;
}
if (event.key === 'F2') {
event.preventDefault();
startProjectRename?.();
return;
}
if (!event.repeat && event.code === 'KeyV' && event.shiftKey) {
event.preventDefault();
switchTool?.('video');
return;
}
if (!event.repeat && !event.shiftKey && isKey(event, 'v', 'KeyV')) {
event.preventDefault();
switchTool?.('select');
return;
}
if (!event.repeat && !event.shiftKey && isKey(event, 'h', 'KeyH')) {
event.preventDefault();
switchTool?.('hand');
return;
}
if (!event.repeat && !event.shiftKey && isKey(event, 'u', 'KeyU')) {
event.preventDefault();
switchTool?.('upload');
return;
}
if (!event.repeat && !event.shiftKey && isKey(event, 'g', 'KeyG')) {
event.preventDefault();
switchTool?.('generate');
return;
}
if (!event.repeat && !event.shiftKey && isKey(event, 'm', 'KeyM')) {
event.preventDefault();
switchTool?.('music');
return;
}
}
if (event.key === 'Shift') {
setShiftPressed(true);
}
@@ -238,6 +442,7 @@ export function useImageCanvasKeyboardShortcuts({
}
if (event.key === 'Escape') {
closeTransientEditorPanels();
clearCanvasSelection?.();
return;
}
if (event.code !== 'Space' || event.repeat || isEditableTarget(event)) {
@@ -265,13 +470,23 @@ export function useImageCanvasKeyboardShortcuts({
};
}, [
closeEditorChromePanels,
clearCanvasSelection,
copySelectedCanvasLayers,
duplicateSelectedCanvasLayers,
deleteSelectedCanvasObjects,
deleteLayerById,
exportCanvasAssets,
fitLayers,
generateDialogRef,
moveSelectedCanvasLayers,
nudgeSelectedCanvasObjects,
pasteCanvasClipboard,
requestRemoveCanvasGenerationDialog,
redoCanvasChange,
selectAllCanvasObjects,
selectedLayerIdRef,
selectedLayerIdsRef,
startProjectRename,
setActiveTool,
setContextMenu,
setGenerateDialog,
@@ -293,6 +508,11 @@ export function useImageCanvasKeyboardShortcuts({
setIsUiDesignSpecMenuOpen,
setQuickEditPanel,
setShiftPressed,
switchTool,
toggleMinimap,
toggleSidebarPanel,
undoCanvasChange,
updateScaleFromCenter,
zoomViewportBy,
]);
}
@@ -139,12 +139,21 @@ function LayerCommandsHarness({
<button type="button" onClick={() => commands.copyContextLayers()}>
复制
</button>
<button type="button" onClick={() => commands.copySelectedLayers()}>
复制选中
</button>
<button
type="button"
onClick={() => commands.copyContextLayers({ cut: true })}
>
剪切
</button>
<button
type="button"
onClick={() => commands.copySelectedLayers({ cut: true })}
>
剪切选中
</button>
<button
type="button"
onClick={() => commands.pasteCanvasClipboard({ x: 500, y: 600 })}
@@ -154,9 +163,15 @@ function LayerCommandsHarness({
<button type="button" onClick={() => commands.duplicateContextLayers()}>
创建副本
</button>
<button type="button" onClick={() => commands.duplicateSelectedLayers()}>
创建选中副本
</button>
<button type="button" onClick={() => commands.moveContextLayers('top')}>
置顶
</button>
<button type="button" onClick={() => commands.moveSelectedLayers('top')}>
选中置顶
</button>
<button type="button" onClick={() => commands.groupContextLayers()}>
创建组
</button>
@@ -258,6 +273,40 @@ describe('useImageCanvasLayerCommands', () => {
);
});
it('copies, cuts, duplicates, and moves selected layers for keyboard shortcuts', () => {
const onDeleteLayerSideEffects = vi.fn();
render(
<LayerCommandsHarness
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '复制选中' }));
expect(screen.getByTestId('clipboard').textContent).toBe('copy:2');
expect(screen.getByTestId('image-context-closed').textContent).toBe('1');
fireEvent.click(screen.getByRole('button', { name: '创建选中副本' }));
expect(screen.getByTestId('layers').textContent).toContain(
'first 副本:42:4',
);
expect(screen.getByTestId('layers').textContent).toContain(
'second 副本:192:5',
);
expect(screen.getByTestId('selection').textContent).toContain(
'layer-copy-',
);
fireEvent.click(screen.getByRole('button', { name: '只选第三层' }));
fireEvent.click(screen.getByRole('button', { name: '选中置顶' }));
expect(screen.getByTestId('layers').textContent).toContain('third:third');
fireEvent.click(screen.getByRole('button', { name: '剪切选中' }));
expect(screen.getByTestId('clipboard').textContent).toBe('cut:1');
expect(screen.getByTestId('layers').textContent).not.toContain('third');
expect(screen.getByTestId('selection').textContent).toBe('-:');
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('third');
});
it('applies layer commands and clears menus without owning menu positioning', () => {
const exportLayerImage = vi.fn();
render(<LayerCommandsHarness exportLayerImage={exportLayerImage} />);
@@ -85,6 +85,14 @@ export function useImageCanvasLayerCommands({
resolveContextTargetLayerIds(menu, selectedLayerIds),
[contextMenu, selectedLayerIds],
);
const getSelectedTargetLayerIds = useCallback(() => {
const targetIds = selectedLayerIds.length
? selectedLayerIds
: selectedLayerId
? [selectedLayerId]
: [];
return getSelectedLayerIds(targetIds);
}, [selectedLayerId, selectedLayerIds]);
const duplicateLayersToPoint = useCallback(
(
@@ -172,6 +180,47 @@ export function useImageCanvasLayerCommands({
],
);
const copySelectedLayers = useCallback(
(options: { cut?: boolean } = {}) => {
const targetIds = getSelectedTargetLayerIds();
const clipboard = createCanvasLayerClipboard(
layers,
targetIds,
options.cut ? 'cut' : 'copy',
);
if (!clipboard) {
return;
}
setCanvasClipboard(clipboard);
if (options.cut) {
captureCanvasHistory();
setLayers((currentLayers) =>
removeCanvasLayers(currentLayers, targetIds),
);
selectSingleLayer(null);
setMetadataLayer((currentLayer) =>
currentLayer && targetIds.includes(currentLayer.id)
? null
: currentLayer,
);
targetIds.forEach((targetId) => onDeleteLayerSideEffects(targetId));
}
setContextMenu(null);
setImageContextMenu(null);
},
[
captureCanvasHistory,
getSelectedTargetLayerIds,
layers,
onDeleteLayerSideEffects,
selectSingleLayer,
setContextMenu,
setImageContextMenu,
setLayers,
setMetadataLayer,
],
);
const duplicateContextLayers = useCallback(() => {
const targetIds = getContextTargetLayerIds();
const targetLayers = getCanvasLayersByIds(layers, targetIds);
@@ -195,6 +244,31 @@ export function useImageCanvasLayerCommands({
setSelectedLayerIds,
]);
const duplicateSelectedLayers = useCallback(() => {
const targetIds = getSelectedTargetLayerIds();
const targetLayers = getCanvasLayersByIds(layers, targetIds);
const nextLayers = duplicateLayersToPoint(targetLayers);
if (!nextLayers.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) => [...currentLayers, ...nextLayers]);
setSelectedLayerIds(nextLayers.map((layer) => layer.id));
setSelectedLayerId(nextLayers[0]?.id ?? null);
setContextMenu(null);
setImageContextMenu(null);
}, [
captureCanvasHistory,
duplicateLayersToPoint,
getSelectedTargetLayerIds,
layers,
setContextMenu,
setImageContextMenu,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
]);
const updateContextLayers = useCallback(
(updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer) => {
const targetIds = getContextTargetLayerIds();
@@ -225,6 +299,28 @@ export function useImageCanvasLayerCommands({
[captureCanvasHistory, getContextTargetLayerIds, setContextMenu, setLayers],
);
const moveSelectedLayers = useCallback(
(mode: CanvasLayerMoveMode) => {
const targetIds = getSelectedTargetLayerIds();
if (!targetIds.length) {
return;
}
captureCanvasHistory();
setLayers((currentLayers) =>
moveCanvasLayers(currentLayers, targetIds, mode),
);
setContextMenu(null);
setImageContextMenu(null);
},
[
captureCanvasHistory,
getSelectedTargetLayerIds,
setContextMenu,
setImageContextMenu,
setLayers,
],
);
const groupContextLayers = useCallback(() => {
const targetIds = getContextTargetLayerIds();
if (!targetIds.length) {
@@ -439,9 +535,12 @@ export function useImageCanvasLayerCommands({
getContextTargetLayerIds,
pasteCanvasClipboard,
copyContextLayers,
copySelectedLayers,
duplicateContextLayers,
duplicateSelectedLayers,
updateContextLayers,
moveContextLayers,
moveSelectedLayers,
groupContextLayers,
ungroupContextLayers,
toggleContextLayerVisibility,
@@ -524,6 +524,33 @@ function StageInteractionsHarness({
>
直接拖生成占位
</button>
<button
type="button"
onClick={(event) => {
const frameElement = document.createElement('div');
const dialog = asCanvasGenerationDialog(generateDialog);
if (!dialog) {
return;
}
interaction.handleGenerationFramePointerDown(
createPointerEvent(frameElement, {
pointerId: 9,
clientX: 300,
clientY: 200,
}),
dialog,
);
interaction.handlePointerMove(
createPointerEvent(getViewportElement(), {
pointerId: 9,
clientX: 220,
clientY: 60,
}),
);
}}
>
直接贴近图层拖生成占位
</button>
<button
type="button"
onClick={(event) => {
@@ -612,10 +639,10 @@ describe('useImageCanvasStageInteractions', () => {
screen.getByRole('button', { name: '直接移动图层' }).click();
});
expect(screen.getByTestId('layers').textContent).toContain(
'first:70.0,60.0',
'first:70.0,70.0',
);
expect(screen.getByTestId('layers').textContent).toContain(
'second:250.0,80.0',
'second:250.0,90.0',
);
act(() => {
@@ -672,6 +699,21 @@ describe('useImageCanvasStageInteractions', () => {
);
});
it('shows snap guides when generation frames drag near layer alignment', () => {
render(<StageInteractionsHarness />);
act(() => {
screen
.getByRole('button', { name: '直接贴近图层拖生成占位' })
.click();
});
expect(screen.getByTestId('dialog-position').textContent).toBe(
'220.0,60.0',
);
expect(screen.getByTestId('snap').textContent).toBe('220');
});
it('selects generation frames and keeps mixed selections movable', () => {
render(<StageInteractionsHarness />);
@@ -500,12 +500,14 @@ export function useImageCanvasStageInteractions({
if (dragState.kind === 'generation-frame') {
const pointer = getPointerClient(event);
const movedDialogs = moveGenerationFramesFromDrag({
const movedFrameResult = moveGenerationFramesFromDrag({
dragState,
generationDialogs: canvasGenerationDialogs,
layers,
pointer,
});
movedDialogs.forEach((nextDialog) => {
setSnapGuide(movedFrameResult.snapGuide);
movedFrameResult.generationDialogs.forEach((nextDialog) => {
if (!dragState.dialogIds.includes(nextDialog.id)) {
return;
}
@@ -515,6 +517,7 @@ export function useImageCanvasStageInteractions({
moveDragSelectedLayers({
dragState,
layers: currentLayers,
snapOffset: movedFrameResult.snapOffset,
pointer,
}),
);
@@ -534,18 +537,25 @@ export function useImageCanvasStageInteractions({
}
const pointer = getPointerClient(event);
const movedLayers = moveLayersFromDrag({ dragState, layers, pointer });
const movedLayers = moveLayersFromDrag({
dragState,
layers,
generationDialogs: canvasGenerationDialogs,
pointer,
});
if (!movedLayers) {
return;
}
setSnapGuide(movedLayers.snapGuide);
setLayers(movedLayers.layers);
const movedDialogs = moveGenerationFramesFromDrag({
const movedFrameResult = moveGenerationFramesFromDrag({
dragState,
generationDialogs: canvasGenerationDialogs,
layers,
snapOffset: movedLayers.snapOffset,
pointer,
});
movedDialogs.forEach((nextDialog) => {
movedFrameResult.generationDialogs.forEach((nextDialog) => {
if (!dragState.generationDialogIds?.includes(nextDialog.id)) {
return;
}
@@ -15253,7 +15253,14 @@ export function PlatformEntryFlowShellImpl({
);
const projectGalleryContent = (
<Suspense fallback={<LazyPanelFallback label="正在加载项目..." />}>
<ProjectGalleryView onOpenProject={openEditorProject} />
<ProjectGalleryView
onOpenProject={openEditorProject}
onReturnToMainSite={() => {
platformBootstrap.setPlatformTab('home');
setSelectionStage('platform');
pushAppHistoryPath('/');
}}
/>
</Suspense>
);
const handlePlatformHomeTabChange = useCallback(
@@ -2,7 +2,7 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import type { ComponentProps, ContextType } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
@@ -72,6 +72,18 @@ const projectItems = [
},
];
function renderProjectGalleryView(
props: Partial<ComponentProps<typeof ProjectGalleryView>> = {},
) {
return render(
<ProjectGalleryView
onOpenProject={vi.fn()}
onReturnToMainSite={vi.fn()}
{...props}
/>,
);
}
describe('ProjectGalleryView', () => {
afterEach(() => {
listEditorProjectsMock.mockReset();
@@ -85,7 +97,7 @@ describe('ProjectGalleryView', () => {
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
renderProjectGalleryView({ onOpenProject });
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板' }));
@@ -93,6 +105,19 @@ describe('ProjectGalleryView', () => {
expect(onOpenProject).toHaveBeenCalledWith('editor-project-1');
});
it('returns to the main site from the project header', async () => {
const onReturnToMainSite = vi.fn();
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
const user = userEvent.setup();
renderProjectGalleryView({ onReturnToMainSite });
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '返回主站' }));
expect(onReturnToMainSite).toHaveBeenCalledTimes(1);
});
it('uses canvas-center layer composition as the project cover', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
{
@@ -143,7 +168,7 @@ describe('ProjectGalleryView', () => {
},
]);
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
renderProjectGalleryView();
await screen.findByText('封面项目');
const cover = document.querySelector('.project-gallery__canvas-cover');
@@ -172,7 +197,10 @@ describe('ProjectGalleryView', () => {
render(
<AuthUiContext.Provider value={createAuthValue({ openLoginModal })}>
<ProjectGalleryView onOpenProject={vi.fn()} />
<ProjectGalleryView
onOpenProject={vi.fn()}
onReturnToMainSite={vi.fn()}
/>
</AuthUiContext.Provider>,
);
@@ -191,7 +219,10 @@ describe('ProjectGalleryView', () => {
render(
<AuthUiContext.Provider value={createAuthValue({ requireAuth })}>
<ProjectGalleryView onOpenProject={onOpenProject} />
<ProjectGalleryView
onOpenProject={onOpenProject}
onReturnToMainSite={vi.fn()}
/>
</AuthUiContext.Provider>,
);
@@ -205,7 +236,7 @@ describe('ProjectGalleryView', () => {
it('renders project loading errors through the shared status message', async () => {
listEditorProjectsMock.mockRejectedValueOnce(new Error('读取项目失败'));
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
renderProjectGalleryView();
const alert = await screen.findByRole('alert');
@@ -223,7 +254,7 @@ describe('ProjectGalleryView', () => {
deleteEditorProjectMock.mockResolvedValueOnce('editor-project-2');
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
renderProjectGalleryView();
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '打开项目角色设定板菜单' }));
@@ -252,7 +283,7 @@ describe('ProjectGalleryView', () => {
deleteEditorProjectMock.mockResolvedValue('deleted');
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={vi.fn()} />);
renderProjectGalleryView();
await screen.findByText('角色设定板');
await user.click(screen.getByRole('button', { name: '选择' }));
@@ -279,7 +310,7 @@ describe('ProjectGalleryView', () => {
});
const user = userEvent.setup();
render(<ProjectGalleryView onOpenProject={onOpenProject} />);
renderProjectGalleryView({ onOpenProject });
const newProjectButton = await screen.findByRole('button', {
name: '新建项目',
+15 -2
View File
@@ -1,4 +1,5 @@
import {
ArrowLeft,
Check,
CheckSquare,
MoreHorizontal,
@@ -34,6 +35,7 @@ import { ProjectCanvasCover } from './ProjectCanvasCover';
type ProjectGalleryViewProps = {
onOpenProject: (projectId: string) => void;
onReturnToMainSite: () => void;
};
type RenameDraft = {
@@ -58,7 +60,10 @@ function formatProjectUpdatedAt(value: string) {
}).format(date);
}
export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
export function ProjectGalleryView({
onOpenProject,
onReturnToMainSite,
}: ProjectGalleryViewProps) {
const authUi = useAuthUi();
const [projects, setProjects] = useState<EditorProjectSnapshot[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -298,11 +303,19 @@ export function ProjectGalleryView({ onOpenProject }: ProjectGalleryViewProps) {
return (
<main className="project-gallery" aria-label="项目">
<header className="project-gallery__header">
<div>
<div className="project-gallery__heading">
<h1>项目</h1>
<span>{projects.length} 个画布项目</span>
</div>
<div className="project-gallery__header-actions">
<PlatformActionButton
tone="secondary"
size="sm"
onClick={onReturnToMainSite}
>
<ArrowLeft className="h-4 w-4" />
返回主站
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
size="sm"
@@ -9195,9 +9195,21 @@ test('project page keeps desktop rail and can return to platform tabs', async ()
expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy();
const nav = container.querySelector('.platform-desktop-rail');
expect(nav).toBeTruthy();
expect(
within(nav as HTMLElement).getByRole('button', { name: '项目' }),
).toBeTruthy();
const createNavButton = within(nav as HTMLElement).getByRole('button', {
name: '创作',
});
const projectNavButton = within(nav as HTMLElement).getByRole('button', {
name: '项目',
});
expect(projectNavButton.className).toContain(
'platform-desktop-rail__button--active',
);
expect(createNavButton.className).not.toContain(
'platform-desktop-rail__button--active',
);
expect(createNavButton.className).not.toContain(
'platform-desktop-rail__button--primary',
);
await user.click(within(nav as HTMLElement).getByRole('button', { name: '推荐' }));
@@ -9207,6 +9219,54 @@ test('project page keeps desktop rail and can return to platform tabs', async ()
expect(window.location.pathname).toBe('/');
});
test('desktop project tab switch clears creation primary styling', async () => {
const user = userEvent.setup();
mockDesktopPlatformLayout();
window.history.replaceState(null, '', '/creation');
const { container } = render(<TestWrapper withAuth />);
await screen.findByRole('main', { name: '陶泥儿创作主页' });
const nav = container.querySelector('.platform-desktop-rail');
expect(nav).toBeTruthy();
await user.click(within(nav as HTMLElement).getByRole('button', { name: '项目' }));
expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy();
const projectNav = container.querySelector('.platform-desktop-rail');
expect(projectNav).toBeTruthy();
const createNavButton = within(projectNav as HTMLElement).getByRole('button', {
name: '创作',
});
const projectNavButton = within(projectNav as HTMLElement).getByRole('button', {
name: '项目',
});
expect(projectNavButton.className).toContain(
'platform-desktop-rail__button--active',
);
expect(createNavButton.className).not.toContain(
'platform-desktop-rail__button--active',
);
expect(createNavButton.className).not.toContain(
'platform-desktop-rail__button--primary',
);
});
test('project page header can return to main site', async () => {
const user = userEvent.setup();
mockDesktopPlatformLayout();
window.history.replaceState(null, '', '/project');
render(<TestWrapper withAuth />);
expect(await screen.findByRole('main', { name: '项目' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回主站' }));
await waitFor(() => {
expect(screen.queryByRole('main', { name: '项目' })).toBeNull();
});
expect(window.location.pathname).toBe('/');
});
test('direct mobile creation home keeps landing content without mobile nav entry', async () => {
window.history.replaceState(null, '', '/creation');
const { container } = render(<TestWrapper withAuth />);
@@ -3819,6 +3819,32 @@ test('desktop nav opens creation home and project gallery from dedicated entries
expect(onOpenProjects).toHaveBeenCalledTimes(1);
});
test('desktop recommend page reuses compact workbench chrome', () => {
const { container } = renderLoggedInHomeView({
activeTab: 'home',
isDesktopLayout: true,
});
const shell = container.querySelector('.platform-desktop-shell');
expect(shell).toBeTruthy();
expect(shell?.className).toContain('platform-desktop-shell--workbench');
const leftbar = container.querySelector('.platform-desktop-leftbar');
const topbar = container.querySelector('.platform-desktop-topbar');
expect(leftbar).toBeTruthy();
expect(topbar).toBeTruthy();
expect(leftbar?.querySelector('.creation-home-brand-lockup')).toBeTruthy();
expect(topbar?.querySelector('.creation-home-brand-lockup')).toBeNull();
expect(
within(leftbar as HTMLElement).getByRole('button', { name: '推荐' }),
).toBeTruthy();
expect(
within(topbar as HTMLElement).getByPlaceholderText(
'搜索作品号、名称、作者、描述',
),
).toBeTruthy();
});
test('logged in saves tab shows loading state while fetching my entries', () => {
renderLoggedInHomeView({
activeTab: 'saves',
+13 -34
View File
@@ -4992,7 +4992,7 @@ export function RpgEntryHomeView({
);
const desktopTopbarActions: ReactNode = (
<div className="platform-desktop-topbar__actions flex items-center gap-3">
{isAuthenticated && (activeTab === 'create' || activeTab === 'saves') ? (
{isAuthenticated ? (
<TopbarWalletShortcutButton
variant="desktop"
balanceLabel={profileDashboardPresentation.walletBalanceWithUnitLabel}
@@ -5037,7 +5037,7 @@ export function RpgEntryHomeView({
label={tabLabels[tab]}
icon={tabIcons[tab]}
creationHomeIconSrc={CREATION_HOME_NAV_ICON_SRC[tab]}
emphasized={tab === 'create'}
emphasized={tab === 'create' && resolvedDesktopNavTab === 'create'}
showDot={false}
disabled={isRechargePaymentConfirmationPending}
onClick={() => {
@@ -5226,48 +5226,27 @@ export function RpgEntryHomeView({
className="flex h-full min-h-0 flex-col"
>
<div className="flex h-full min-h-0 flex-col">
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
{allowHiddenActiveTab ? (
<div className="platform-desktop-layout flex min-h-0 flex-1">
<aside className="platform-desktop-leftbar flex shrink-0 flex-col">
{desktopBrandLockup}
{desktopRailNav}
</aside>
<div className="platform-desktop-shell platform-desktop-shell--workbench flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-layout flex min-h-0 flex-1">
<aside className="platform-desktop-leftbar flex shrink-0 flex-col">
{desktopBrandLockup}
{desktopRailNav}
</aside>
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="platform-desktop-topbar__brand-search flex min-w-0 flex-1 items-center gap-5">
{desktopSearchBar}
</div>
{desktopTopbarActions}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</div>
</div>
) : (
<>
<div className="platform-desktop-main flex min-w-0 flex-1 flex-col">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="platform-desktop-topbar__brand-search flex min-w-0 flex-1 items-center gap-5">
{desktopBrandLockup}
{desktopSearchBar}
</div>
{desktopTopbarActions}
</div>
<div className="mt-5 flex min-h-0 gap-5">
{desktopRailNav}
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}
</div>
</>
)}
</div>
</div>
</div>
</div>
</div>

Some files were not shown because too many files have changed in this diff Show More