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 { MindmapElementModel } from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import { type Bound } from '@blocksuite/global/gfx';
import {
type DragExtensionInitializeContext,
type ExtensionDragMoveContext,
@@ -74,47 +74,63 @@ export class SnapExtension extends InteractivityExtension {
return {};
}
let alignBound: Bound | null = null;
return {
onResizeStart(context) {
alignBound = snapOverlay.setMovingElements(context.elements);
snapOverlay.setMovingElements(context.elements);
},
onResizeMove(context) {
if (!alignBound || alignBound.w === 0 || alignBound.h === 0) {
return;
const {
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;
let { dx, dy } = context;
if (lockRatio) {
const min = Math.min(
Math.abs(dx / alignBound.w),
Math.abs(dy / alignBound.h)
if (alignDirection.length > 0) {
const rst = snapOverlay.alignResize(
currentHandlePos,
alignDirection
);
dx = min * Math.sign(dx) * alignBound.w;
dy = min * Math.sign(dy) * alignBound.h;
const dx = switchDirection ? ny * rst.dy : nx * rst.dx;
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() {
alignBound = null;
snapOverlay.clear();
},
};

View File

@@ -3,7 +3,7 @@ import {
ConnectorElementModel,
MindmapElementModel,
} 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';
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 } {
const rst = { dx: 0, dy: 0 };
const threshold = ALIGN_THRESHOLD / this.gfx.viewport.zoom;

View File

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

View File

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

View File

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