Merge branch 'codex/editor-asset-library' of https://git.genarrative.world/git/GenarrativeAI/Genarrative into codex/editor-asset-library
This commit is contained in:
@@ -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: '新建项目',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user