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

@@ -13,6 +13,12 @@ import type {
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from '../types/drag.js';
import type {
ExtensionElementResizeContext,
ExtensionElementResizeEndContext,
ExtensionElementResizeMoveContext,
ExtensionElementResizeStartContext,
} from '../types/resize.js';
import type { ExtensionElementSelectContext } from '../types/select.js';
export const InteractivityExtensionIdentifier =
@@ -100,7 +106,7 @@ export class InteractivityEventAPI {
}
}
type ActionContextMap = {
export type ActionContextMap = {
dragInitialize: {
context: DragExtensionInitializeContext;
returnType: {
@@ -119,6 +125,14 @@ type ActionContextMap = {
| undefined
>;
};
elementResize: {
context: ExtensionElementResizeContext;
returnType: {
onResizeStart?: (context: ExtensionElementResizeStartContext) => void;
onResizeMove?: (context: ExtensionElementResizeMoveContext) => void;
onResizeEnd?: (context: ExtensionElementResizeEndContext) => void;
};
};
elementSelect: {
context: ExtensionElementSelectContext;
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(
handler: (
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 { createInteractionContext, type SupportedEvents } from './event.js';
import {
type ActionContextMap,
type InteractivityActionAPI,
type InteractivityEventAPI,
InteractivityExtensionIdentifier,
@@ -882,7 +883,11 @@ export class InteractivityManager extends GfxExtension {
handleElementResize(
options: Omit<
OptionResize,
'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate'
| 'lockRatio'
| 'onResizeStart'
| 'onResizeEnd'
| 'onResizeUpdate'
| 'onResizeMove'
> & {
onResizeStart?: () => void;
onResizeEnd?: () => void;
@@ -910,6 +915,23 @@ export class InteractivityManager extends GfxExtension {
const elements = Array.from(viewConfigMap.values()).map(
config => config.view.model
) 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;
viewConfigMap.forEach(config => {
@@ -925,11 +947,46 @@ export class InteractivityManager extends GfxExtension {
...options,
lockRatio,
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 }) => {
this.activeInteraction$.value = {
type: 'resize',
elements,
};
extensionHandlers.forEach(ext => {
ext.onResizeStart?.({
elements,
handle,
});
});
options.onResizeStart?.();
data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) {
@@ -990,6 +1047,13 @@ export class InteractivityManager extends GfxExtension {
},
onResizeEnd: ({ data }) => {
this.activeInteraction$.value = null;
extensionHandlers.forEach(ext => {
ext.onResizeEnd?.({
elements,
handle,
});
});
options.onResizeEnd?.();
this.std.store.transact(() => {
data.forEach(({ model }) => {

View File

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