fix: tuning drag and resize snapping (#12657)

### Changed
- Better snapping when resize elements

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

- **New Features**
  - Improved resizing behavior with enhanced alignment and snapping during element resizing, supporting rotation and multiple element selection.
  - Alignment lines now display more accurately when resizing elements.

- **Refactor**
  - Resizing logic updated to use scale factors instead of position deltas, enabling smoother and more precise resize operations.
  - Resize event data now includes richer details about handle positions, scaling, and original bounds.
  - Coordinate transformations and scaling now account for rotation and aspect ratio locking more robustly.
  - Cursor updates are disabled during active resize or rotate interactions for a smoother user experience.

- **Tests**
  - Updated resizing tests to use square shapes, ensuring consistent verification of aspect ratio maintenance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
doouding
2025-06-03 04:12:57 +00:00
parent 39830a410a
commit 00ff373c01
7 changed files with 378 additions and 199 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 { Bound } from '@blocksuite/global/gfx'; import { type Bound } from '@blocksuite/global/gfx';
import { import {
type DragExtensionInitializeContext, type DragExtensionInitializeContext,
type ExtensionDragMoveContext, type ExtensionDragMoveContext,
@@ -74,47 +74,63 @@ export class SnapExtension extends InteractivityExtension {
return {}; return {};
} }
let alignBound: Bound | null = null;
return { return {
onResizeStart(context) { onResizeStart(context) {
alignBound = snapOverlay.setMovingElements(context.elements); snapOverlay.setMovingElements(context.elements);
}, },
onResizeMove(context) { onResizeMove(context) {
if (!alignBound || alignBound.w === 0 || alignBound.h === 0) { const {
return; handle,
originalBound,
scaleX,
scaleY,
handleSign,
currentHandlePos,
elements,
} = context;
const rotate = elements.length > 1 ? 0 : elements[0].rotate;
const alignDirection: ('vertical' | 'horizontal')[] = [];
let switchDirection = false;
let nx = handleSign.x;
let ny = handleSign.y;
if (handle.length > 6) {
alignDirection.push('vertical', 'horizontal');
} else if (rotate % 90 === 0) {
nx =
handleSign.x * Math.cos((rotate / 180) * Math.PI) -
handleSign.y * Math.sin((rotate / 180) * Math.PI);
ny =
handleSign.x * Math.sin((rotate / 180) * Math.PI) +
handleSign.y * Math.cos((rotate / 180) * Math.PI);
if (Math.abs(nx) > Math.abs(ny)) {
alignDirection.push('horizontal');
} else {
alignDirection.push('vertical');
}
if (rotate % 180 !== 0) {
switchDirection = true;
}
} }
const { handle, handleSign, lockRatio } = context; if (alignDirection.length > 0) {
let { dx, dy } = context; const rst = snapOverlay.alignResize(
currentHandlePos,
if (lockRatio) { alignDirection
const min = Math.min(
Math.abs(dx / alignBound.w),
Math.abs(dy / alignBound.h)
); );
dx = min * Math.sign(dx) * alignBound.w; const dx = switchDirection ? ny * rst.dy : nx * rst.dx;
dy = min * Math.sign(dy) * alignBound.h; const dy = switchDirection ? nx * rst.dx : ny * rst.dy;
context.suggest({
scaleX: scaleX + dx / originalBound.w,
scaleY: scaleY + dy / originalBound.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() { onResizeEnd() {
alignBound = null;
snapOverlay.clear(); snapOverlay.clear();
}, },
}; };

View File

@@ -3,7 +3,7 @@ import {
ConnectorElementModel, ConnectorElementModel,
MindmapElementModel, MindmapElementModel,
} from '@blocksuite/affine-model'; } from '@blocksuite/affine-model';
import { almostEqual, Bound, Point } from '@blocksuite/global/gfx'; import { almostEqual, Bound, type IVec, Point } from '@blocksuite/global/gfx';
import type { GfxModel } from '@blocksuite/std/gfx'; import type { GfxModel } from '@blocksuite/std/gfx';
interface Distance { interface Distance {
@@ -586,6 +586,60 @@ export class SnapOverlay extends Overlay {
); );
} }
alignResize(position: IVec, direction: ('vertical' | 'horizontal')[]) {
const rst = { dx: 0, dy: 0 };
const { viewport } = this.gfx;
const threshold = ALIGN_THRESHOLD / viewport.zoom;
const searchBound = new Bound(
position[0] - threshold / 2,
position[1] - threshold / 2,
threshold,
threshold
);
const alignBound = new Bound(position[0], position[1], 0, 0);
this._intraGraphicAlignLines = {
horizontal: [],
vertical: [],
};
this._distributedAlignLines = [];
this._updateAlignCandidates(searchBound);
for (const other of this._referenceBounds.all) {
const closestDistances = this._calculateClosestDistances(
alignBound,
other
);
if (
direction.includes('horizontal') &&
closestDistances.horiz &&
(!this._intraGraphicAlignLines.horizontal.length ||
Math.abs(closestDistances.horiz.distance) < Math.abs(rst.dx))
) {
this._updateXAlignPoint(rst, alignBound, other, closestDistances);
}
if (
direction.includes('vertical') &&
closestDistances.vert &&
(!this._intraGraphicAlignLines.vertical.length ||
Math.abs(closestDistances.vert.distance) < Math.abs(rst.dy))
) {
this._updateYAlignPoint(rst, alignBound, other, closestDistances);
}
}
this._intraGraphicAlignLines.horizontal =
this._intraGraphicAlignLines.horizontal.slice(0, 1);
this._intraGraphicAlignLines.vertical =
this._intraGraphicAlignLines.vertical.slice(0, 1);
this._renderer?.refresh();
return rst;
}
align(bound: Bound): { dx: number; dy: number } { align(bound: Bound): { dx: number; dy: number } {
const rst = { dx: 0, dy: 0 }; const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom; const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;

View File

@@ -374,6 +374,8 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
type: 'resize' | 'rotate'; type: 'resize' | 'rotate';
angle: number; angle: number;
handle: ResizeHandle; handle: ResizeHandle;
flipX?: boolean;
flipY?: boolean;
pure?: boolean; pure?: boolean;
}) => { }) => {
if (!options) { if (!options) {
@@ -381,8 +383,25 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
return 'default'; return 'default';
} }
const { type, angle, handle } = options; const { type, angle, flipX, flipY } = options;
let cursor: CursorType = 'default'; let cursor: CursorType = 'default';
let handle: ResizeHandle = options.handle;
if (flipX) {
handle = (
handle.includes('left')
? handle.replace('left', 'right')
: handle.replace('right', 'left')
) as ResizeHandle;
}
if (flipY) {
handle = (
handle.includes('top')
? handle.replace('top', 'bottom')
: handle.replace('bottom', 'top')
) as ResizeHandle;
}
if (type === 'rotate') { if (type === 'rotate') {
cursor = generateCursorUrl(angle, handle); cursor = generateCursorUrl(angle, handle);
@@ -626,7 +645,7 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
onResizeStart: () => { onResizeStart: () => {
this._mode = 'resize'; this._mode = 'resize';
}, },
onResizeUpdate: ({ lockRatio, scaleX, exceed }) => { onResizeUpdate: ({ lockRatio, scaleX, scaleY, exceed }) => {
if (lockRatio) { if (lockRatio) {
this._scaleDirection = handle; this._scaleDirection = handle;
this._scalePercent = `${Math.round(scaleX * 100)}%`; this._scalePercent = `${Math.round(scaleX * 100)}%`;
@@ -642,6 +661,8 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
type: 'resize', type: 'resize',
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0), angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),
handle, handle,
flipX: scaleX < 0,
flipY: scaleY < 0,
}); });
}, },
onResizeEnd: () => { onResizeEnd: () => {
@@ -652,6 +673,14 @@ export class EdgelessSelectedRectWidget extends WidgetComponent<RootBlockModel>
} }
}, },
option => { option => {
if (
['resize', 'rotate'].includes(
interaction.activeInteraction$.value?.type ?? ''
)
) {
return '';
}
return this._updateCursor({ return this._updateCursor({
...option, ...option,
angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0), angle: elements.length > 1 ? 0 : (elements[0]?.rotate ?? 0),

View File

@@ -947,23 +947,34 @@ export class InteractivityManager extends GfxExtension {
...options, ...options,
lockRatio, lockRatio,
elements, elements,
onResizeMove: ({ dx, dy, handleSign, lockRatio }) => { onResizeMove: ({
scaleX,
scaleY,
originalBound,
handleSign,
handlePos,
currentHandlePos,
lockRatio,
}) => {
const suggested: { const suggested: {
dx: number; scaleX: number;
dy: number; scaleY: number;
priority?: number; priority?: number;
}[] = []; }[] = [];
const suggest = (distance: { dx: number; dy: number }) => { const suggest = (distance: { scaleX: number; scaleY: number }) => {
suggested.push(distance); suggested.push(distance);
}; };
extensionHandlers.forEach(ext => { extensionHandlers.forEach(ext => {
ext.onResizeMove?.({ ext.onResizeMove?.({
dx, scaleX,
dy, scaleY,
elements, elements,
handleSign,
handle, handle,
handleSign,
handlePos,
originalBound,
currentHandlePos,
lockRatio, lockRatio,
suggest, suggest,
}); });
@@ -973,9 +984,9 @@ export class InteractivityManager extends GfxExtension {
return (a.priority ?? 0) - (b.priority ?? 0); return (a.priority ?? 0) - (b.priority ?? 0);
}); });
return last(suggested) ?? { dx, dy }; return last(suggested) ?? { scaleX, scaleY };
}, },
onResizeStart: ({ data }) => { onResizeStart: ({ handleSign, handlePos, data }) => {
this.activeInteraction$.value = { this.activeInteraction$.value = {
type: 'resize', type: 'resize',
elements, elements,
@@ -984,6 +995,8 @@ export class InteractivityManager extends GfxExtension {
ext.onResizeStart?.({ ext.onResizeStart?.({
elements, elements,
handle, handle,
handlePos,
handleSign,
}); });
}); });
@@ -1045,13 +1058,15 @@ export class InteractivityManager extends GfxExtension {
options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed }); options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed });
}, },
onResizeEnd: ({ data }) => { onResizeEnd: ({ handleSign, handlePos, data }) => {
this.activeInteraction$.value = null; this.activeInteraction$.value = null;
extensionHandlers.forEach(ext => { extensionHandlers.forEach(ext => {
ext.onResizeEnd?.({ ext.onResizeEnd?.({
elements, elements,
handle, handle,
handlePos,
handleSign,
}); });
}); });
options.onResizeEnd?.(); options.onResizeEnd?.();

View File

@@ -2,6 +2,7 @@ import {
Bound, Bound,
getCommonBoundWithRotation, getCommonBoundWithRotation,
type IBound, type IBound,
type IPoint,
type IVec, type IVec,
} from '@blocksuite/global/gfx'; } from '@blocksuite/global/gfx';
@@ -29,7 +30,7 @@ export const DEFAULT_HANDLES: ResizeHandle[] = [
'bottom', 'bottom',
]; ];
type ElementInitialSnapshot = Readonly<Required<IBound>>; type ReadonlyIBound = Readonly<Required<IBound>>;
export interface OptionResize { export interface OptionResize {
elements: GfxModel[]; elements: GfxModel[];
@@ -37,16 +38,18 @@ export interface OptionResize {
lockRatio: boolean; lockRatio: boolean;
event: PointerEvent; event: PointerEvent;
onResizeMove: (payload: { onResizeMove: (payload: {
dx: number; scaleX: number;
dy: number; scaleY: number;
handleSign: { originalBound: IBound;
xSign: number;
ySign: number; handleSign: IPoint;
};
handlePos: IVec;
currentHandlePos: IVec;
lockRatio: boolean; lockRatio: boolean;
}) => { dx: number; dy: number }; }) => { scaleX: number; scaleY: number };
onResizeUpdate: (payload: { onResizeUpdate: (payload: {
lockRatio: boolean; lockRatio: boolean;
scaleX: number; scaleX: number;
@@ -59,8 +62,16 @@ export interface OptionResize {
matrix: DOMMatrix; matrix: DOMMatrix;
}[]; }[];
}) => void; }) => void;
onResizeStart?: (payload: { data: { model: GfxModel }[] }) => void; onResizeStart?: (payload: {
onResizeEnd?: (payload: { data: { model: GfxModel }[] }) => void; handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
onResizeEnd?: (payload: {
handlePos: IVec;
handleSign: IPoint;
data: { model: GfxModel }[];
}) => void;
} }
export type RotateOption = { export type RotateOption = {
@@ -95,11 +106,102 @@ export class ResizeController {
this.gfx = option.gfx; this.gfx = option.gfx;
} }
getCoordsTransform(originalBound: IBound, handle: ResizeHandle) {
const { x: xSign, y: ySign } = this.getHandleSign(handle);
const pivot = new DOMPoint(
originalBound.x + ((-xSign + 1) / 2) * originalBound.w,
originalBound.y + ((-ySign + 1) / 2) * originalBound.h
);
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocalRotatedM = new DOMMatrix()
.translate(-pivot.x, -pivot.y)
.translate(
originalBound.w / 2 + originalBound.x,
originalBound.h / 2 + originalBound.y
)
.rotate(-(originalBound.rotate ?? 0))
.translate(
-(originalBound.w / 2 + originalBound.x),
-(originalBound.h / 2 + originalBound.y)
);
const toLocal = (p: DOMPoint, withRotation: boolean = false) =>
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse());
const handlePos = toModel(
new DOMPoint(originalBound.w * xSign, originalBound.h * ySign)
);
return {
xSign,
ySign,
originalBound,
toLocalM,
toLocalRotatedM,
toLocal,
toModel,
handlePos: [handlePos.x, handlePos.y] as IVec,
};
}
getScaleFromDelta(
transform: ReturnType<ResizeController['getCoordsTransform']>,
delta: { dx: number; dy: number },
handleStartPos: IVec,
lockRatio: boolean
) {
const { originalBound, xSign, ySign, toModel, toLocal } = transform;
const currentPos = toLocal(
new DOMPoint(handleStartPos[0] + delta.dx, handleStartPos[1] + delta.dy),
true
);
let scaleX = xSign ? currentPos.x / (originalBound.w * xSign) : 1;
let scaleY = ySign ? currentPos.y / (originalBound.h * ySign) : 1;
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
const finalHandlePos = toModel(
new DOMPoint(
originalBound.w * xSign * scaleX,
originalBound.h * ySign * scaleY
)
);
return {
scaleX,
scaleY,
handlePos: [finalHandlePos.x, finalHandlePos.y] as IVec,
};
}
getScaleMatrix(
{ scaleX, scaleY }: { scaleX: number; scaleY: number },
lockRatio: boolean
) {
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
return {
scaleX,
scaleY,
scaleM: new DOMMatrix().scaleSelf(scaleX, scaleY),
};
}
startResize(options: OptionResize) { startResize(options: OptionResize) {
const { const {
elements, elements,
handle, handle,
lockRatio,
onResizeStart, onResizeStart,
onResizeMove, onResizeMove,
onResizeUpdate, onResizeUpdate,
@@ -107,19 +209,32 @@ export class ResizeController {
event, event,
} = options; } = options;
const originals: ElementInitialSnapshot[] = elements.map(el => ({ const originals: ReadonlyIBound[] = elements.map(el => ({
x: el.x, x: el.x,
y: el.y, y: el.y,
w: el.w, w: el.w,
h: el.h, h: el.h,
rotate: el.rotate, rotate: el.rotate,
})); }));
const originalBound = getCommonBoundWithRotation(originals); const originalBound: IBound =
originals.length > 1
? getCommonBoundWithRotation(originals)
: {
x: originals[0].x,
y: originals[0].y,
w: originals[0].w,
h: originals[0].h,
rotate: originals[0].rotate,
};
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 transform = this.getCoordsTransform(originalBound, handle);
const handleSign = {
x: transform.xSign,
y: transform.ySign,
};
const onPointerMove = (e: PointerEvent) => { const onPointerMove = (e: PointerEvent) => {
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([ const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
@@ -130,45 +245,69 @@ export class ResizeController {
dx: currPt[0] - startPt[0], dx: currPt[0] - startPt[0],
dy: currPt[1] - startPt[1], dy: currPt[1] - startPt[1],
}; };
const shouldLockRatio = lockRatio || e.shiftKey; const shouldLockRatio =
options.lockRatio || e.shiftKey || elements.length > 1;
const {
scaleX,
scaleY,
handlePos: currentHandlePos,
} = this.getScaleFromDelta(
transform,
delta,
transform.handlePos,
shouldLockRatio
);
const scale = onResizeMove({
scaleX,
scaleY,
originalBound,
delta = onResizeMove({
dx: delta.dx,
dy: delta.dy,
handleSign, handleSign,
handlePos: transform.handlePos,
currentHandlePos,
lockRatio: shouldLockRatio, lockRatio: shouldLockRatio,
}); });
const scaleInfo = this.getScaleMatrix(scale, shouldLockRatio);
if (elements.length === 1) { if (elements.length === 1) {
this.resizeSingle( this.resizeSingle(
originals[0], originals[0],
elements[0], elements[0],
shouldLockRatio, shouldLockRatio,
startPt, transform,
delta, scaleInfo,
handleSign,
onResizeUpdate onResizeUpdate
); );
} else { } else {
this.resizeMulti( this.resizeMulti(
originalBound,
originals, originals,
elements, elements,
startPt, transform,
delta, scaleInfo,
handleSign,
onResizeUpdate onResizeUpdate
); );
} }
}; };
onResizeStart?.({ data: elements.map(model => ({ model })) }); onResizeStart?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
const onPointerUp = () => { const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove); this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp); this.host.removeEventListener('pointerup', onPointerUp);
onResizeEnd?.({ data: elements.map(model => ({ model })) }); onResizeEnd?.({
handleSign,
handlePos: transform.handlePos,
data: elements.map(model => ({ model })),
});
}; };
this.host.addEventListener('pointermove', onPointerMove); this.host.addEventListener('pointermove', onPointerMove);
@@ -176,55 +315,15 @@ export class ResizeController {
} }
private resizeSingle( private resizeSingle(
orig: ElementInitialSnapshot, orig: ReadonlyIBound,
model: GfxModel, model: GfxModel,
lockRatio: boolean, lockRatio: boolean,
startPt: IVec, transform: ReturnType<typeof ResizeController.prototype.getCoordsTransform>,
delta: { scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate'] updateCallback: OptionResize['onResizeUpdate']
) { ) {
const { xSign, ySign } = handleSign; const { toLocalM, toLocalRotatedM, toLocal, toModel } = transform;
const { scaleX, scaleY, scaleM } = scale;
const pivot = new DOMPoint(
orig.x + (-xSign === 1 ? orig.w : 0),
orig.y + (-ySign === 1 ? orig.h : 0)
);
const toLocalRotatedM = new DOMMatrix()
.translate(-pivot.x, -pivot.y)
.translate(orig.w / 2 + orig.x, orig.h / 2 + orig.y)
.rotate(-orig.rotate)
.translate(-(orig.w / 2 + orig.x), -(orig.h / 2 + orig.y));
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocal = (p: DOMPoint, withRotation: boolean) =>
p.matrixTransform(withRotation ? toLocalRotatedM : toLocalM);
const toModel = (p: DOMPoint) =>
p.matrixTransform(toLocalRotatedM.inverse());
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
: 1;
let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + orig.h) / orig.h
: 1;
if (lockRatio) {
const min = Math.min(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
}
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
const [visualTopLeft, visualBottomRight] = [ const [visualTopLeft, visualBottomRight] = [
new DOMPoint(orig.x, orig.y), new DOMPoint(orig.x, orig.y),
@@ -282,45 +381,14 @@ export class ResizeController {
} }
private resizeMulti( private resizeMulti(
originalBound: Bound, originals: ReadonlyIBound[],
originals: ElementInitialSnapshot[],
elements: GfxModel[], elements: GfxModel[],
startPt: IVec, transform: ReturnType<ResizeController['getCoordsTransform']>,
delta: { scale: { scaleX: number; scaleY: number; scaleM: DOMMatrix },
dx: number;
dy: number;
},
handleSign: { xSign: number; ySign: number },
updateCallback: OptionResize['onResizeUpdate'] updateCallback: OptionResize['onResizeUpdate']
) { ) {
const { xSign, ySign } = handleSign; const { toLocalM } = transform;
const pivot = new DOMPoint( const { scaleX, scaleY, scaleM } = scale;
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 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) + originalBound.w) /
originalBound.w
: 1;
let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + originalBound.h) /
originalBound.h
: 1;
const min = Math.max(Math.abs(scaleX), Math.abs(scaleY));
scaleX = Math.sign(scaleX) * min;
scaleY = Math.sign(scaleY) * min;
const scaleM = new DOMMatrix().scale(scaleX, scaleY);
const data = elements.map((model, i) => { const data = elements.map((model, i) => {
const orig = originals[i]; const orig = originals[i];
@@ -357,7 +425,7 @@ export class ResizeController {
startRotate(option: RotateOption) { startRotate(option: RotateOption) {
const { event, elements, onRotateUpdate } = option; const { event, elements, onRotateUpdate } = option;
const originals: ElementInitialSnapshot[] = elements.map(el => ({ const originals: ReadonlyIBound[] = elements.map(el => ({
x: el.x, x: el.x,
y: el.y, y: el.y,
w: el.w, w: el.w,
@@ -429,7 +497,7 @@ export class ResizeController {
} }
private rotateSingle(option: { private rotateSingle(option: {
orig: ElementInitialSnapshot; orig: ReadonlyIBound;
model: GfxModel; model: GfxModel;
startPt: IVec; startPt: IVec;
currentPt: IVec; currentPt: IVec;
@@ -481,7 +549,7 @@ export class ResizeController {
} }
private rotateMulti(option: { private rotateMulti(option: {
origs: ElementInitialSnapshot[]; origs: ReadonlyIBound[];
models: GfxModel[]; models: GfxModel[];
startPt: IVec; startPt: IVec;
currentPt: IVec; currentPt: IVec;
@@ -567,23 +635,23 @@ export class ResizeController {
private getHandleSign(handle: ResizeHandle) { private getHandleSign(handle: ResizeHandle) {
switch (handle) { switch (handle) {
case 'top-left': case 'top-left':
return { xSign: -1, ySign: -1 }; return { x: -1, y: -1 };
case 'top': case 'top':
return { xSign: 0, ySign: -1 }; return { x: 0, y: -1 };
case 'top-right': case 'top-right':
return { xSign: 1, ySign: -1 }; return { x: 1, y: -1 };
case 'right': case 'right':
return { xSign: 1, ySign: 0 }; return { x: 1, y: 0 };
case 'bottom-right': case 'bottom-right':
return { xSign: 1, ySign: 1 }; return { x: 1, y: 1 };
case 'bottom': case 'bottom':
return { xSign: 0, ySign: 1 }; return { x: 0, y: 1 };
case 'bottom-left': case 'bottom-left':
return { xSign: -1, ySign: 1 }; return { x: -1, y: 1 };
case 'left': case 'left':
return { xSign: -1, ySign: 0 }; return { x: -1, y: 0 };
default: default:
return { xSign: 0, ySign: 0 }; return { x: 0, y: 0 };
} }
} }
} }

View File

@@ -1,3 +1,5 @@
import type { IBound, IPoint, IVec } from '@blocksuite/global/gfx';
import type { GfxModel } from '../../model/model'; import type { GfxModel } from '../../model/model';
import type { ResizeHandle } from '../resize/manager'; import type { ResizeHandle } from '../resize/manager';
@@ -8,6 +10,16 @@ export type ExtensionElementResizeContext = {
export type ExtensionElementResizeStartContext = { export type ExtensionElementResizeStartContext = {
elements: GfxModel[]; elements: GfxModel[];
/**
* The position of the handle in the browser coordinate space.
*/
handlePos: IVec;
/**
* The sign (or normal vector) of the handle.
*/
handleSign: IPoint;
handle: ResizeHandle; handle: ResizeHandle;
}; };
@@ -16,15 +28,14 @@ export type ExtensionElementResizeEndContext =
export type ExtensionElementResizeMoveContext = export type ExtensionElementResizeMoveContext =
ExtensionElementResizeStartContext & { ExtensionElementResizeStartContext & {
dx: number; scaleX: number;
dy: number; scaleY: number;
originalBound: IBound;
currentHandlePos: IVec;
lockRatio: boolean; lockRatio: boolean;
handleSign: { suggest: (distance: { scaleX: number; scaleY: number }) => void;
xSign: number;
ySign: number;
};
suggest: (distance: { dx: number; dy: number }) => void;
}; };

View File

@@ -37,23 +37,16 @@ test.describe('resizing shapes and aspect ratio will be maintained', () => {
await addBasicRectShapeElement( await addBasicRectShapeElement(
page, page,
{ x: 210, y: 110 }, { x: 210, y: 210 },
{ x: 310, y: 210 } { x: 310, y: 310 }
); );
await page.mouse.click(220, 120); await page.mouse.click(220, 220);
await assertEdgelessSelectedRect(page, [210, 110, 100, 100]);
await dragBetweenCoords(page, { x: 120, y: 90 }, { x: 220, y: 130 }); await dragBetweenCoords(page, { x: 120, y: 90 }, { x: 220, y: 220 });
await assertEdgelessSelectedRect(page, [98, 98, 212, 112]); await assertEdgelessSelectedRect(page, [98, 98, 212, 212]);
await resizeElementByHandle(page, { x: 50, y: 50 }); await resizeElementByHandle(page, { x: 50, y: 50 });
await assertEdgelessSelectedRect(page, [148, 124.19, 162, 85.81]); await assertEdgelessSelectedRect(page, [148, 148, 162, 162]);
await page.mouse.move(160, 160);
await assertEdgelessSelectedRect(page, [148, 124.19, 162, 85.81]);
await page.mouse.move(260, 160);
await assertEdgelessSelectedRect(page, [148, 124.19, 162, 85.81]);
}); });
test('negative adjustment', async ({ page }) => { test('negative adjustment', async ({ page }) => {
@@ -73,23 +66,16 @@ test.describe('resizing shapes and aspect ratio will be maintained', () => {
await addBasicRectShapeElement( await addBasicRectShapeElement(
page, page,
{ x: 210, y: 110 }, { x: 210, y: 210 },
{ x: 310, y: 210 } { x: 310, y: 310 }
); );
await page.mouse.click(220, 120); await page.mouse.click(220, 220);
await assertEdgelessSelectedRect(page, [210, 110, 100, 100]);
await dragBetweenCoords(page, { x: 120, y: 90 }, { x: 220, y: 130 }); await dragBetweenCoords(page, { x: 120, y: 90 }, { x: 220, y: 220 });
await assertEdgelessSelectedRect(page, [98, 98, 212, 112]); await assertEdgelessSelectedRect(page, [98, 98, 212, 212]);
await resizeElementByHandle(page, { x: 400, y: 300 }, 'top-left', 30); await resizeElementByHandle(page, { x: 50, y: 50 }, 'bottom-right', 30);
await assertEdgelessSelectedRect(page, [310, 210, 356, 188]); await assertEdgelessSelectedRect(page, [98, 98, 262, 262]);
await page.mouse.move(450, 300);
await assertEdgelessSelectedRect(page, [310, 210, 356, 188]);
await page.mouse.move(320, 220);
await assertEdgelessSelectedRect(page, [310, 210, 356, 188]);
}); });
}); });