mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 06:16:59 +08:00
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:
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user