feat: support snap when resizing element (#12563)

Fixes [BS-2753](https://linear.app/affine-design/issue/BS-2753/)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added snapping support when resizing elements, improving alignment and precision during resize operations.
  - Introduced new resize event handlers allowing extensions to customize resize behavior with start, move, and end callbacks.

- **Bug Fixes**
  - Improved handling of snapping state to prevent errors during drag and resize actions.

- **Tests**
  - Updated resizing tests to ensure consistent snapping behavior by removing default elements that could interfere with test results.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
doouding
2025-05-28 02:47:01 +00:00
parent f5f959692a
commit cf456c888f
6 changed files with 253 additions and 34 deletions

View File

@@ -1,6 +1,6 @@
import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
import { MindmapElementModel } from '@blocksuite/affine-model'; import { MindmapElementModel } from '@blocksuite/affine-model';
import type { Bound } from '@blocksuite/global/gfx'; import { Bound } from '@blocksuite/global/gfx';
import { import {
type DragExtensionInitializeContext, type DragExtensionInitializeContext,
type ExtensionDragMoveContext, type ExtensionDragMoveContext,
@@ -28,7 +28,7 @@ export class SnapExtension extends InteractivityExtension {
return {}; return {};
} }
let alignBound: Bound; let alignBound: Bound | null = null;
return { return {
onDragStart() { onDragStart() {
@@ -46,6 +46,7 @@ export class SnapExtension extends InteractivityExtension {
onDragMove(context: ExtensionDragMoveContext) { onDragMove(context: ExtensionDragMoveContext) {
if ( if (
context.elements.length === 0 || context.elements.length === 0 ||
!alignBound ||
alignBound.w === 0 || alignBound.w === 0 ||
alignBound.h === 0 alignBound.h === 0
) { ) {
@@ -58,11 +59,65 @@ export class SnapExtension extends InteractivityExtension {
context.dx = alignRst.dx + context.dx; context.dx = alignRst.dx + context.dx;
context.dy = alignRst.dy + context.dy; context.dy = alignRst.dy + context.dy;
}, },
onDragEnd() { clear() {
alignBound = null;
snapOverlay.clear(); snapOverlay.clear();
}, },
}; };
} }
); );
this.action.onElementResize(() => {
const snapOverlay = this.snapOverlay;
if (!snapOverlay) {
return {};
}
let alignBound: Bound | null = null;
return {
onResizeStart(context) {
alignBound = snapOverlay.setMovingElements(context.elements);
},
onResizeMove(context) {
if (!alignBound || alignBound.w === 0 || alignBound.h === 0) {
return;
}
const { handle, handleSign, lockRatio } = context;
let { dx, dy } = context;
if (lockRatio) {
const min = Math.min(
Math.abs(dx / alignBound.w),
Math.abs(dy / alignBound.h)
);
dx = min * Math.sign(dx) * alignBound.w;
dy = min * Math.sign(dy) * alignBound.h;
}
const currentBound = new Bound(
alignBound.x +
(handle.includes('left') ? -dx * handleSign.xSign : 0),
alignBound.y +
(handle.includes('top') ? -dy * handleSign.ySign : 0),
Math.abs(alignBound.w + dx * handleSign.xSign),
Math.abs(alignBound.h + dy * handleSign.ySign)
);
const alignRst = snapOverlay.align(currentBound);
context.suggest({
dx: alignRst.dx + context.dx,
dy: alignRst.dy + context.dy,
});
},
onResizeEnd() {
alignBound = null;
snapOverlay.clear();
},
};
});
} }
} }

View File

@@ -13,6 +13,12 @@ import type {
ExtensionDragMoveContext, ExtensionDragMoveContext,
ExtensionDragStartContext, ExtensionDragStartContext,
} from '../types/drag.js'; } from '../types/drag.js';
import type {
ExtensionElementResizeContext,
ExtensionElementResizeEndContext,
ExtensionElementResizeMoveContext,
ExtensionElementResizeStartContext,
} from '../types/resize.js';
import type { ExtensionElementSelectContext } from '../types/select.js'; import type { ExtensionElementSelectContext } from '../types/select.js';
export const InteractivityExtensionIdentifier = export const InteractivityExtensionIdentifier =
@@ -100,7 +106,7 @@ export class InteractivityEventAPI {
} }
} }
type ActionContextMap = { export type ActionContextMap = {
dragInitialize: { dragInitialize: {
context: DragExtensionInitializeContext; context: DragExtensionInitializeContext;
returnType: { returnType: {
@@ -119,6 +125,14 @@ type ActionContextMap = {
| undefined | undefined
>; >;
}; };
elementResize: {
context: ExtensionElementResizeContext;
returnType: {
onResizeStart?: (context: ExtensionElementResizeStartContext) => void;
onResizeMove?: (context: ExtensionElementResizeMoveContext) => void;
onResizeEnd?: (context: ExtensionElementResizeEndContext) => void;
};
};
elementSelect: { elementSelect: {
context: ExtensionElementSelectContext; context: ExtensionElementSelectContext;
returnType: void; returnType: void;
@@ -144,6 +158,18 @@ export class InteractivityActionAPI {
}; };
} }
onElementResize(
handler: (
ctx: ActionContextMap['elementResize']['context']
) => ActionContextMap['elementResize']['returnType']
) {
this._handlers['elementResize'] = handler;
return () => {
return delete this._handlers['elementResize'];
};
}
onRequestElementsClone( onRequestElementsClone(
handler: ( handler: (
ctx: ActionContextMap['elementsClone']['context'] ctx: ActionContextMap['elementsClone']['context']

View File

@@ -14,6 +14,7 @@ import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import type { GfxElementModelView } from '../view/view.js'; import type { GfxElementModelView } from '../view/view.js';
import { createInteractionContext, type SupportedEvents } from './event.js'; import { createInteractionContext, type SupportedEvents } from './event.js';
import { import {
type ActionContextMap,
type InteractivityActionAPI, type InteractivityActionAPI,
type InteractivityEventAPI, type InteractivityEventAPI,
InteractivityExtensionIdentifier, InteractivityExtensionIdentifier,
@@ -882,7 +883,11 @@ export class InteractivityManager extends GfxExtension {
handleElementResize( handleElementResize(
options: Omit< options: Omit<
OptionResize, OptionResize,
'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate' | 'lockRatio'
| 'onResizeStart'
| 'onResizeEnd'
| 'onResizeUpdate'
| 'onResizeMove'
> & { > & {
onResizeStart?: () => void; onResizeStart?: () => void;
onResizeEnd?: () => void; onResizeEnd?: () => void;
@@ -910,6 +915,23 @@ export class InteractivityManager extends GfxExtension {
const elements = Array.from(viewConfigMap.values()).map( const elements = Array.from(viewConfigMap.values()).map(
config => config.view.model config => config.view.model
) as GfxModel[]; ) as GfxModel[];
const extensionHandlers = this.interactExtensions.values().reduce(
(handlers, ext) => {
const extHandlers = (ext.action as InteractivityActionAPI).emit(
'elementResize',
{
elements,
}
);
if (extHandlers) {
handlers.push(extHandlers);
}
return handlers;
},
[] as ActionContextMap['elementResize']['returnType'][]
);
let lockRatio = false; let lockRatio = false;
viewConfigMap.forEach(config => { viewConfigMap.forEach(config => {
@@ -925,11 +947,46 @@ export class InteractivityManager extends GfxExtension {
...options, ...options,
lockRatio, lockRatio,
elements, elements,
onResizeMove: ({ dx, dy, handleSign, lockRatio }) => {
const suggested: {
dx: number;
dy: number;
priority?: number;
}[] = [];
const suggest = (distance: { dx: number; dy: number }) => {
suggested.push(distance);
};
extensionHandlers.forEach(ext => {
ext.onResizeMove?.({
dx,
dy,
elements,
handleSign,
handle,
lockRatio,
suggest,
});
});
suggested.sort((a, b) => {
return (a.priority ?? 0) - (b.priority ?? 0);
});
return last(suggested) ?? { dx, dy };
},
onResizeStart: ({ data }) => { onResizeStart: ({ data }) => {
this.activeInteraction$.value = { this.activeInteraction$.value = {
type: 'resize', type: 'resize',
elements, elements,
}; };
extensionHandlers.forEach(ext => {
ext.onResizeStart?.({
elements,
handle,
});
});
options.onResizeStart?.(); options.onResizeStart?.();
data.forEach(({ model }) => { data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) { if (!viewConfigMap.has(model.id)) {
@@ -990,6 +1047,13 @@ export class InteractivityManager extends GfxExtension {
}, },
onResizeEnd: ({ data }) => { onResizeEnd: ({ data }) => {
this.activeInteraction$.value = null; this.activeInteraction$.value = null;
extensionHandlers.forEach(ext => {
ext.onResizeEnd?.({
elements,
handle,
});
});
options.onResizeEnd?.(); options.onResizeEnd?.();
this.std.store.transact(() => { this.std.store.transact(() => {
data.forEach(({ model }) => { data.forEach(({ model }) => {

View File

@@ -1,6 +1,7 @@
import { import {
Bound, Bound,
getCommonBoundWithRotation, getCommonBoundWithRotation,
type IBound,
type IVec, type IVec,
} from '@blocksuite/global/gfx'; } from '@blocksuite/global/gfx';
@@ -28,19 +29,24 @@ export const DEFAULT_HANDLES: ResizeHandle[] = [
'bottom', 'bottom',
]; ];
interface ElementInitialSnapshot { type ElementInitialSnapshot = Readonly<Required<IBound>>;
x: number;
y: number;
w: number;
h: number;
rotate: number;
}
export interface OptionResize { export interface OptionResize {
elements: GfxModel[]; elements: GfxModel[];
handle: ResizeHandle; handle: ResizeHandle;
lockRatio: boolean; lockRatio: boolean;
event: PointerEvent; event: PointerEvent;
onResizeMove: (payload: {
dx: number;
dy: number;
handleSign: {
xSign: number;
ySign: number;
};
lockRatio: boolean;
}) => { dx: number; dy: number };
onResizeUpdate: (payload: { onResizeUpdate: (payload: {
lockRatio: boolean; lockRatio: boolean;
scaleX: number; scaleX: number;
@@ -95,6 +101,7 @@ export class ResizeController {
handle, handle,
lockRatio, lockRatio,
onResizeStart, onResizeStart,
onResizeMove,
onResizeUpdate, onResizeUpdate,
onResizeEnd, onResizeEnd,
event, event,
@@ -107,35 +114,49 @@ export class ResizeController {
h: el.h, h: el.h,
rotate: el.rotate, rotate: el.rotate,
})); }));
const originalBound = getCommonBoundWithRotation(originals);
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([ const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX, event.clientX,
event.clientY, event.clientY,
]); ]);
const handleSign = this.getHandleSign(handle);
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([ const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
e.clientX, e.clientX,
e.clientY, e.clientY,
]); ]);
let delta = {
dx: currPt[0] - startPt[0],
dy: currPt[1] - startPt[1],
};
const shouldLockRatio = lockRatio || e.shiftKey; const shouldLockRatio = lockRatio || e.shiftKey;
delta = onResizeMove({
dx: delta.dx,
dy: delta.dy,
handleSign,
lockRatio: shouldLockRatio,
});
if (elements.length === 1) { if (elements.length === 1) {
this.resizeSingle( this.resizeSingle(
originals[0], originals[0],
elements[0], elements[0],
shouldLockRatio, shouldLockRatio,
startPt, startPt,
currPt, delta,
handle, handleSign,
onResizeUpdate onResizeUpdate
); );
} else { } else {
this.resizeMulti( this.resizeMulti(
originalBound,
originals, originals,
elements, elements,
handle,
currPt,
startPt, startPt,
delta,
handleSign,
onResizeUpdate onResizeUpdate
); );
} }
@@ -159,11 +180,14 @@ export class ResizeController {
model: GfxModel, model: GfxModel,
lockRatio: boolean, lockRatio: boolean,
startPt: IVec, startPt: IVec,
currPt: IVec, delta: {
handle: ResizeHandle, dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate'] updateCallback: OptionResize['onResizeUpdate']
) { ) {
const { xSign, ySign } = this.getHandleSign(handle); const { xSign, ySign } = handleSign;
const pivot = new DOMPoint( const pivot = new DOMPoint(
orig.x + (-xSign === 1 ? orig.w : 0), orig.x + (-xSign === 1 ? orig.w : 0),
@@ -181,8 +205,11 @@ export class ResizeController {
const toModel = (p: DOMPoint) => const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse()); p.matrixTransform(toLocalRotatedM.inverse());
const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]), true);
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), true); const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), true);
const currPtLocal = toLocal(
new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy),
true
);
let scaleX = xSign let scaleX = xSign
? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w ? (xSign * (currPtLocal.x - handleLocal.x) + orig.w) / orig.w
@@ -255,34 +282,38 @@ export class ResizeController {
} }
private resizeMulti( private resizeMulti(
originalBound: Bound,
originals: ElementInitialSnapshot[], originals: ElementInitialSnapshot[],
elements: GfxModel[], elements: GfxModel[],
handle: ResizeHandle,
currPt: IVec,
startPt: IVec, startPt: IVec,
delta: {
dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate'] updateCallback: OptionResize['onResizeUpdate']
) { ) {
const commonBound = getCommonBoundWithRotation(originals); const { xSign, ySign } = handleSign;
const { xSign, ySign } = this.getHandleSign(handle);
const pivot = new DOMPoint( const pivot = new DOMPoint(
commonBound.x + ((-xSign + 1) / 2) * commonBound.w, originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
commonBound.y + ((-ySign + 1) / 2) * commonBound.h originalBound.y + ((-ySign + 1) / 2) * originalBound.h
); );
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y); const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM); const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]));
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1])); const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]));
const currPtLocal = toLocal(
new DOMPoint(startPt[0] + delta.dx, startPt[1] + delta.dy)
);
let scaleX = xSign let scaleX = xSign
? (xSign * (currPtLocal.x - handleLocal.x) + commonBound.w) / ? (xSign * (currPtLocal.x - handleLocal.x) + originalBound.w) /
commonBound.w originalBound.w
: 1; : 1;
let scaleY = ySign let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + commonBound.h) / ? (ySign * (currPtLocal.y - handleLocal.y) + originalBound.h) /
commonBound.h originalBound.h
: 1; : 1;
const min = Math.max(Math.abs(scaleX), Math.abs(scaleY)); const min = Math.max(Math.abs(scaleX), Math.abs(scaleY));

View File

@@ -0,0 +1,30 @@
import type { GfxModel } from '../../model/model';
import type { ResizeHandle } from '../resize/manager';
export type ExtensionElementResizeContext = {
elements: GfxModel[];
};
export type ExtensionElementResizeStartContext = {
elements: GfxModel[];
handle: ResizeHandle;
};
export type ExtensionElementResizeEndContext =
ExtensionElementResizeStartContext;
export type ExtensionElementResizeMoveContext =
ExtensionElementResizeStartContext & {
dx: number;
dy: number;
lockRatio: boolean;
handleSign: {
xSign: number;
ySign: number;
};
suggest: (distance: { dx: number; dy: number }) => void;
};

View File

@@ -1,13 +1,16 @@
import { import {
selectElementInEdgeless,
switchEditorMode, switchEditorMode,
zoomResetByKeyboard, zoomResetByKeyboard,
} from '../utils/actions/edgeless.js'; } from '../utils/actions/edgeless.js';
import { import {
addBasicBrushElement, addBasicBrushElement,
addBasicRectShapeElement, addBasicRectShapeElement,
clickView,
dragBetweenCoords, dragBetweenCoords,
enterPlaygroundRoom, enterPlaygroundRoom,
initEmptyEdgelessState, initEmptyEdgelessState,
pressBackspace,
resizeElementByHandle, resizeElementByHandle,
} from '../utils/actions/index.js'; } from '../utils/actions/index.js';
import { import {
@@ -19,10 +22,15 @@ import { test } from '../utils/playwright.js';
test.describe('resizing shapes and aspect ratio will be maintained', () => { test.describe('resizing shapes and aspect ratio will be maintained', () => {
test('positive adjustment', async ({ page }) => { test('positive adjustment', async ({ page }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initEmptyEdgelessState(page); const { noteId } = await initEmptyEdgelessState(page);
await switchEditorMode(page); await switchEditorMode(page);
await zoomResetByKeyboard(page); await zoomResetByKeyboard(page);
// delete note to avoid snapping to it
await clickView(page, [0, 0]);
await selectElementInEdgeless(page, [noteId]);
await pressBackspace(page);
await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 }); await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 });
await page.mouse.click(110, 110); await page.mouse.click(110, 110);
await assertEdgelessSelectedRect(page, [98, 98, 104, 104]); await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);
@@ -50,10 +58,15 @@ test.describe('resizing shapes and aspect ratio will be maintained', () => {
test('negative adjustment', async ({ page }) => { test('negative adjustment', async ({ page }) => {
await enterPlaygroundRoom(page); await enterPlaygroundRoom(page);
await initEmptyEdgelessState(page); const { noteId } = await initEmptyEdgelessState(page);
await switchEditorMode(page); await switchEditorMode(page);
await zoomResetByKeyboard(page); await zoomResetByKeyboard(page);
// delete note to avoid snapping to it
await clickView(page, [0, 0]);
await selectElementInEdgeless(page, [noteId]);
await pressBackspace(page);
await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 }); await addBasicBrushElement(page, { x: 100, y: 100 }, { x: 200, y: 200 });
await page.mouse.click(110, 110); await page.mouse.click(110, 110);
await assertEdgelessSelectedRect(page, [98, 98, 104, 104]); await assertEdgelessSelectedRect(page, [98, 98, 104, 104]);