增加画布元素吸附功能
新增画布元素边缘、中心线与等距吸附计算。 接入图层与生成占位拖拽吸附,并保持多选队形同步偏移。 补充画布吸附模型和交互测试。 更新图片画布编辑器方案文档中的吸附验收口径。
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -589,4 +589,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,
|
||||
@@ -34,6 +35,7 @@ export const MAX_SCALE = 3.2;
|
||||
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;
|
||||
@@ -1086,43 +1088,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,
|
||||
@@ -1137,6 +1174,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[],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user