增加画布元素吸附功能

新增画布元素边缘、中心线与等距吸附计算。

接入图层与生成占位拖拽吸附,并保持多选队形同步偏移。

补充画布吸附模型和交互测试。

更新图片画布编辑器方案文档中的吸附验收口径。
This commit is contained in:
2026-06-22 17:22:01 +08:00
parent 139ef3b3fa
commit 8b18a654eb
8 changed files with 566 additions and 47 deletions
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;
}