mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
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:
@@ -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']
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user