refactor(editor): rewrite resize and rotate (#12054)

### Changed

This pr split the old `edgeless-selected-rect` into four focused modules:

- `edgeless-selected-rect`: Provide an entry point for user operation on view layer only, no further logic here.

- `GfxViewInteractionExtension`: Allow you to plug in custom resize/rotate behaviors for block or canvas element. If you don’t register an extension, it falls back to the default behaviours.

- `InteractivityManager`: Provide the API that accepts resize/rotate requests, invokes any custom behaviors you’ve registered, tracks the lifecycle and intermediate state, then hands off to the math engine.

- `ResizeController`: A pure math engine that listens for pointer moves and pointer ups and calculates new sizes, positions, and angles. It doesn’t call any business APIs.

### Customizing an element’s resize/rotate behavior
Call `GfxViewInteractionExtension` with the element’s flavour or type plus a config object. In the config you can define:

- `resizeConstraint` (min/max width & height, lock ratio)
- `handleResize(context)` method that returns an object containing `beforeResize`、`onResizeStart`、`onResizeMove`、`onResizeEnd`
- `handleRotate(context)` method that returns an object containing `beforeRotate`、`onRotateStart`、`onRotateMove`、`onRotateEnd`

```typescript
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';

GfxViewInteractionExtension(
  flavourOrElementType,
  {
    resizeConstraint: {
      minWidth,
      maxWidth,
      lockRatio,
      minHeight,
      maxHeight
    },
    handleResize(context) {
      return {
        beforeResize(context) {},
        onResizeStart(context) {},
        onResizeMove(context) {},
        onResizeEnd(context) {}
      };
    },
    handleRotate(context) {
      return {
        beforeRotate(context) {},
        onRotateStart(context) {},
        onRotateMove(context) {},
        onRotateEnd(context) {}
      };
    }
  }
);
```

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

- **New Features**
  - Added interaction extensions for edgeless variants of attachment, bookmark, edgeless text, embedded docs, images, notes, frames, AI chat blocks, and various embed blocks (Figma, GitHub, HTML, iframe, Loom, YouTube).
  - Introduced interaction extensions for graphical elements including connectors, groups, mind maps, shapes, and text, supporting constrained resizing and rotation disabling where applicable.
  - Implemented a unified interaction extension framework enabling configurable resize and rotate lifecycle handlers.
  - Enhanced autocomplete overlay behavior based on selection context.

- **Refactor**
  - Removed legacy resize manager and element-specific resize/rotate logic, replacing with a centralized, extensible interaction system.
  - Simplified resize handle rendering to a data-driven approach with improved cursor management.
  - Replaced complex cursor rotation calculations with fixed-angle mappings for resize handles.
  - Streamlined selection rectangle component to use interactivity services for resize and rotate handling.

- **Bug Fixes**
  - Fixed connector update triggers to reduce unnecessary updates.
  - Improved resize constraints enforcement and interaction state tracking.

- **Tests**
  - Refined end-to-end tests to use higher-level resize utilities and added finer-grained assertions on element dimensions.
  - Enhanced mouse movement granularity in drag tests for better simulation fidelity.

- **Chores**
  - Added new workspace dependencies and project references for the interaction framework modules.
  - Extended public API exports to include new interaction types and extensions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
doouding
2025-05-13 11:29:58 +00:00
parent 4ebeb530e0
commit 08d6c5a97c
79 changed files with 2529 additions and 2106 deletions

View File

@@ -691,6 +691,12 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
})
);
_disposables.add(
gfx.selection.slots.updated.subscribe(() => {
this.requestUpdate();
})
);
_disposables.add(() => this.removeOverlay());
_disposables.add(
@@ -716,6 +722,15 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
});
}
private _canAutoComplete() {
const selection = this.gfx.selection;
return (
selection.selectedElements.length === 1 &&
(selection.selectedElements[0] instanceof ShapeElementModel ||
isNoteBlock(selection.selectedElements[0]))
);
}
removeOverlay() {
this._timer && clearTimeout(this._timer);
const surface = getSurfaceComponent(this.std);
@@ -727,7 +742,10 @@ export class EdgelessAutoComplete extends WithDisposable(LitElement) {
const isShape = this.current instanceof ShapeElementModel;
const isMindMap = this.current.group instanceof MindmapElementModel;
if (this._isMoving || (this._isHover && !isShape)) {
if (
this._isMoving ||
(this._isHover && !isShape && this._canAutoComplete())
) {
this.removeOverlay();
return nothing;
}

View File

@@ -1,5 +1,6 @@
import type { IVec } from '@blocksuite/global/gfx';
import type { ResizeHandle } from '@blocksuite/std/gfx';
import { html, nothing } from 'lit';
import { repeat } from 'lit/directives/repeat.js';
export enum HandleDirection {
Bottom = 'bottom',
@@ -12,62 +13,51 @@ export enum HandleDirection {
TopRight = 'top-right',
}
function ResizeHandle(
handleDirection: HandleDirection,
onPointerDown?: (e: PointerEvent, direction: HandleDirection) => void,
updateCursor?: (
dragging: boolean,
options?: {
type: 'resize' | 'rotate';
target?: HTMLElement;
point?: IVec;
}
) => void,
hideEdgeHandle?: boolean
function ResizeHandleRenderer(
handle: ResizeHandle,
rotatable: boolean,
onPointerDown?: (e: PointerEvent, direction: ResizeHandle) => void,
updateCursor?: (options?: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => void
) {
const handlerPointerDown = (e: PointerEvent) => {
e.stopPropagation();
onPointerDown && onPointerDown(e, handleDirection);
onPointerDown && onPointerDown(e, handle);
};
const pointerEnter = (type: 'resize' | 'rotate') => (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
const { clientX, clientY } = e;
const target = e.target as HTMLElement;
const point: IVec = [clientX, clientY];
updateCursor(true, { type, point, target });
updateCursor({ type, handle });
};
const pointerLeave = (e: PointerEvent) => {
e.stopPropagation();
if (e.buttons === 1 || !updateCursor) return;
updateCursor(false);
updateCursor();
};
const rotationTpl =
handleDirection === HandleDirection.Top ||
handleDirection === HandleDirection.Bottom ||
handleDirection === HandleDirection.Left ||
handleDirection === HandleDirection.Right
? nothing
: html`<div
handle.length > 6 && rotatable
? html`<div
class="rotate"
@pointerover=${pointerEnter('rotate')}
@pointerout=${pointerLeave}
></div>`;
></div>`
: nothing;
return html`<div
class="handle"
aria-label=${handleDirection}
aria-label=${handle}
@pointerdown=${handlerPointerDown}
>
${rotationTpl}
<div
class="resize${hideEdgeHandle && ' transparent-handle'}"
class="resize transparent-handle"
@pointerover=${pointerEnter('resize')}
@pointerout=${pointerLeave}
></div>
@@ -85,135 +75,21 @@ function ResizeHandle(
*/
export type ResizeMode = 'edge' | 'all' | 'none' | 'corner' | 'edgeAndCorner';
export function ResizeHandles(
resizeMode: ResizeMode,
onPointerDown: (e: PointerEvent, direction: HandleDirection) => void,
updateCursor?: (
dragging: boolean,
options?: {
type: 'resize' | 'rotate';
target?: HTMLElement;
point?: IVec;
}
) => void
export function RenderResizeHandles(
resizeHandles: ResizeHandle[],
rotatable: boolean,
onPointerDown: (e: PointerEvent, direction: ResizeHandle) => void,
updateCursor?: (options?: {
type: 'resize' | 'rotate';
handle: ResizeHandle;
}) => void
) {
const getCornerHandles = () => {
const handleTopLeft = ResizeHandle(
HandleDirection.TopLeft,
onPointerDown,
updateCursor
);
const handleTopRight = ResizeHandle(
HandleDirection.TopRight,
onPointerDown,
updateCursor
);
const handleBottomLeft = ResizeHandle(
HandleDirection.BottomLeft,
onPointerDown,
updateCursor
);
const handleBottomRight = ResizeHandle(
HandleDirection.BottomRight,
onPointerDown,
updateCursor
);
return {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
};
};
const getEdgeHandles = (hideEdgeHandle?: boolean) => {
const handleLeft = ResizeHandle(
HandleDirection.Left,
onPointerDown,
updateCursor,
hideEdgeHandle
);
const handleRight = ResizeHandle(
HandleDirection.Right,
onPointerDown,
updateCursor,
hideEdgeHandle
);
return { handleLeft, handleRight };
};
const getEdgeVerticalHandles = (hideEdgeHandle?: boolean) => {
const handleTop = ResizeHandle(
HandleDirection.Top,
onPointerDown,
updateCursor,
hideEdgeHandle
);
const handleBottom = ResizeHandle(
HandleDirection.Bottom,
onPointerDown,
updateCursor,
hideEdgeHandle
);
return { handleTop, handleBottom };
};
switch (resizeMode) {
case 'corner': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
// prettier-ignore
return html`
${handleTopLeft}
${handleTopRight}
${handleBottomLeft}
${handleBottomRight}
`;
}
case 'edge': {
const { handleLeft, handleRight } = getEdgeHandles();
return html`${handleLeft} ${handleRight}`;
}
case 'all': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
const { handleLeft, handleRight } = getEdgeHandles(true);
const { handleTop, handleBottom } = getEdgeVerticalHandles(true);
// prettier-ignore
return html`
${handleTopLeft}
${handleTop}
${handleTopRight}
${handleRight}
${handleBottomRight}
${handleBottom}
${handleBottomLeft}
${handleLeft}
`;
}
case 'edgeAndCorner': {
const {
handleTopLeft,
handleTopRight,
handleBottomLeft,
handleBottomRight,
} = getCornerHandles();
const { handleLeft, handleRight } = getEdgeHandles(true);
return html`
${handleTopLeft} ${handleTopRight} ${handleRight} ${handleBottomRight}
${handleBottomLeft} ${handleLeft}
`;
}
case 'none': {
return nothing;
}
}
return html`
${repeat(
resizeHandles,
handle => handle,
handle =>
ResizeHandleRenderer(handle, rotatable, onPointerDown, updateCursor)
)}
`;
}

View File

@@ -1,705 +0,0 @@
import { NOTE_MIN_WIDTH } from '@blocksuite/affine-model';
import {
Bound,
getQuadBoundWithRotation,
type IPoint,
type IVec,
type PointLocation,
rotatePoints,
} from '@blocksuite/global/gfx';
import type { SelectableProps } from '../../utils/query.js';
import { HandleDirection, type ResizeMode } from './resize-handles.js';
// 15deg
const SHIFT_LOCKING_ANGLE = Math.PI / 12;
type DragStartHandler = () => void;
type DragEndHandler = () => void;
type ResizeMoveHandler = (
bounds: Map<
string,
{
bound: Bound;
path?: PointLocation[];
matrix?: DOMMatrix;
}
>,
direction: HandleDirection
) => void;
type RotateMoveHandler = (point: IPoint, rotate: number) => void;
export class HandleResizeManager {
private _aspectRatio = 1;
private _bounds = new Map<
string,
{
bound: Bound;
rotate: number;
}
>();
/**
* Current rect of selected elements, it may change during resizing or moving
*/
private _currentRect = new DOMRect();
private _dragDirection: HandleDirection = HandleDirection.Left;
private _dragging = false;
private _dragPos: {
start: { x: number; y: number };
end: { x: number; y: number };
} = {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
};
private _locked = false;
private readonly _onDragEnd: DragEndHandler;
private readonly _onDragStart: DragStartHandler;
private readonly _onResizeMove: ResizeMoveHandler;
private readonly _onRotateMove: RotateMoveHandler;
private _origin: { x: number; y: number } = { x: 0, y: 0 };
/**
* Record inital rect of selected elements
*/
private _originalRect = new DOMRect();
private _proportion = false;
private _proportional = false;
private _resizeMode: ResizeMode = 'none';
private _rotate = 0;
private _rotation = false;
private _shiftKey = false;
private _target: HTMLElement | null = null;
private _zoom = 1;
onPointerDown = (
e: PointerEvent,
direction: HandleDirection,
proportional = false
) => {
// Prevent selection action from being triggered
e.stopPropagation();
this._locked = false;
this._target = e.target as HTMLElement;
this._dragDirection = direction;
this._dragPos.start = { x: e.x, y: e.y };
this._dragPos.end = { x: e.x, y: e.y };
this._rotation = this._target.classList.contains('rotate');
this._proportional = proportional;
if (this._rotation) {
const rect = this._target
.closest('.affine-edgeless-selected-rect')
?.getBoundingClientRect();
if (!rect) {
return;
}
const { left, top, right, bottom } = rect;
const x = (left + right) / 2;
const y = (top + bottom) / 2;
// center of `selected-rect` in viewport
this._origin = { x, y };
}
this._dragging = true;
this._onDragStart();
const _onPointerMove = ({ x, y, shiftKey }: PointerEvent) => {
if (this._resizeMode === 'none') return;
this._shiftKey = shiftKey;
this._dragPos.end = { x, y };
const proportional = this._proportional || this._shiftKey;
if (this._rotation) {
this._onRotate(proportional);
return;
}
this._onResize(proportional);
};
const _onPointerUp = (_: PointerEvent) => {
this._dragging = false;
this._onDragEnd();
const { x, y, width, height } = this._currentRect;
this._originalRect = new DOMRect(x, y, width, height);
this._locked = true;
this._shiftKey = false;
this._rotation = false;
this._dragPos = {
start: { x: 0, y: 0 },
end: { x: 0, y: 0 },
};
document.removeEventListener('pointermove', _onPointerMove);
document.removeEventListener('pointerup', _onPointerUp);
};
document.addEventListener('pointermove', _onPointerMove);
document.addEventListener('pointerup', _onPointerUp);
};
get bounds() {
return this._bounds;
}
get currentRect() {
return this._currentRect;
}
get dragDirection() {
return this._dragDirection;
}
get dragging() {
return this._dragging;
}
get originalRect() {
return this._originalRect;
}
get rotation() {
return this._rotation;
}
constructor(
onDragStart: DragStartHandler,
onResizeMove: ResizeMoveHandler,
onRotateMove: RotateMoveHandler,
onDragEnd: DragEndHandler
) {
this._onDragStart = onDragStart;
this._onResizeMove = onResizeMove;
this._onRotateMove = onRotateMove;
this._onDragEnd = onDragEnd;
}
private _onResize(proportion: boolean) {
const {
_aspectRatio,
_dragDirection,
_dragPos,
_rotate,
_resizeMode,
_zoom,
_originalRect,
_currentRect,
} = this;
proportion ||= this._proportion;
const isAll = _resizeMode === 'all';
const isCorner = _resizeMode === 'corner';
const isEdgeAndCorner = _resizeMode === 'edgeAndCorner';
const {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
} = _dragPos;
const { left: minX, top: minY, right: maxX, bottom: maxY } = _originalRect;
const original = {
w: maxX - minX,
h: maxY - minY,
cx: (minX + maxX) / 2,
cy: (minY + maxY) / 2,
};
const rect = { ...original };
const scale = { x: 1, y: 1 };
const flip = { x: 1, y: 1 };
const direction = { x: 1, y: 1 };
const fixedPoint = new DOMPoint(0, 0);
const draggingPoint = new DOMPoint(0, 0);
const deltaX = (endX - startX) / _zoom;
const deltaY = (endY - startY) / _zoom;
const m0 = new DOMMatrix()
.translateSelf(original.cx, original.cy)
.rotateSelf(_rotate)
.translateSelf(-original.cx, -original.cy);
if (isCorner || isAll || isEdgeAndCorner) {
switch (_dragDirection) {
case HandleDirection.TopLeft: {
direction.x = -1;
direction.y = -1;
fixedPoint.x = maxX;
fixedPoint.y = maxY;
draggingPoint.x = minX;
draggingPoint.y = minY;
break;
}
case HandleDirection.TopRight: {
direction.x = 1;
direction.y = -1;
fixedPoint.x = minX;
fixedPoint.y = maxY;
draggingPoint.x = maxX;
draggingPoint.y = minY;
break;
}
case HandleDirection.BottomRight: {
direction.x = 1;
direction.y = 1;
fixedPoint.x = minX;
fixedPoint.y = minY;
draggingPoint.x = maxX;
draggingPoint.y = maxY;
break;
}
case HandleDirection.BottomLeft: {
direction.x = -1;
direction.y = 1;
fixedPoint.x = maxX;
fixedPoint.y = minY;
draggingPoint.x = minX;
draggingPoint.y = maxY;
break;
}
case HandleDirection.Left: {
direction.x = -1;
direction.y = 1;
fixedPoint.x = maxX;
fixedPoint.y = original.cy;
draggingPoint.x = minX;
draggingPoint.y = original.cy;
break;
}
case HandleDirection.Right: {
direction.x = 1;
direction.y = 1;
fixedPoint.x = minX;
fixedPoint.y = original.cy;
draggingPoint.x = maxX;
draggingPoint.y = original.cy;
break;
}
case HandleDirection.Top: {
const cx = (minX + maxX) / 2;
direction.x = 1;
direction.y = -1;
fixedPoint.x = cx;
fixedPoint.y = maxY;
draggingPoint.x = cx;
draggingPoint.y = minY;
break;
}
case HandleDirection.Bottom: {
const cx = (minX + maxX) / 2;
direction.x = 1;
direction.y = 1;
fixedPoint.x = cx;
fixedPoint.y = minY;
draggingPoint.x = cx;
draggingPoint.y = maxY;
break;
}
}
// force adjustment by aspect ratio
proportion ||= this._bounds.size > 1;
const fp = fixedPoint.matrixTransform(m0);
let dp = draggingPoint.matrixTransform(m0);
dp.x += deltaX;
dp.y += deltaY;
if (
_dragDirection === HandleDirection.Left ||
_dragDirection === HandleDirection.Right ||
_dragDirection === HandleDirection.Top ||
_dragDirection === HandleDirection.Bottom
) {
const dpo = draggingPoint.matrixTransform(m0);
const coorPoint: IVec = [0, 0];
const [[x1, y1]] = rotatePoints([[dpo.x, dpo.y]], coorPoint, -_rotate);
const [[x2, y2]] = rotatePoints([[dp.x, dp.y]], coorPoint, -_rotate);
const point = { x: 0, y: 0 };
if (
_dragDirection === HandleDirection.Left ||
_dragDirection === HandleDirection.Right
) {
point.x = x2;
point.y = y1;
} else {
point.x = x1;
point.y = y2;
}
const [[x3, y3]] = rotatePoints(
[[point.x, point.y]],
coorPoint,
_rotate
);
dp.x = x3;
dp.y = y3;
}
const cx = (fp.x + dp.x) / 2;
const cy = (fp.y + dp.y) / 2;
const m1 = new DOMMatrix()
.translateSelf(cx, cy)
.rotateSelf(-_rotate)
.translateSelf(-cx, -cy);
const f = fp.matrixTransform(m1);
const d = dp.matrixTransform(m1);
switch (_dragDirection) {
case HandleDirection.TopLeft: {
rect.w = f.x - d.x;
rect.h = f.y - d.y;
break;
}
case HandleDirection.TopRight: {
rect.w = d.x - f.x;
rect.h = f.y - d.y;
break;
}
case HandleDirection.BottomRight: {
rect.w = d.x - f.x;
rect.h = d.y - f.y;
break;
}
case HandleDirection.BottomLeft: {
rect.w = f.x - d.x;
rect.h = d.y - f.y;
break;
}
case HandleDirection.Left: {
rect.w = f.x - d.x;
break;
}
case HandleDirection.Right: {
rect.w = d.x - f.x;
break;
}
case HandleDirection.Top: {
rect.h = f.y - d.y;
break;
}
case HandleDirection.Bottom: {
rect.h = d.y - f.y;
break;
}
}
rect.cx = (d.x + f.x) / 2;
rect.cy = (d.y + f.y) / 2;
scale.x = rect.w / original.w;
scale.y = rect.h / original.h;
flip.x = scale.x < 0 ? -1 : 1;
flip.y = scale.y < 0 ? -1 : 1;
const isDraggingCorner =
_dragDirection === HandleDirection.TopLeft ||
_dragDirection === HandleDirection.TopRight ||
_dragDirection === HandleDirection.BottomRight ||
_dragDirection === HandleDirection.BottomLeft;
// lock aspect ratio
if (proportion && isDraggingCorner) {
const newAspectRatio = Math.abs(rect.w / rect.h);
if (_aspectRatio < newAspectRatio) {
scale.y = Math.abs(scale.x) * flip.y;
rect.h = scale.y * original.h;
} else {
scale.x = Math.abs(scale.y) * flip.x;
rect.w = scale.x * original.w;
}
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
draggingPoint.y = fixedPoint.y + rect.h * direction.y;
dp = draggingPoint.matrixTransform(m0);
rect.cx = (fp.x + dp.x) / 2;
rect.cy = (fp.y + dp.y) / 2;
}
} else {
// handle notes
switch (_dragDirection) {
case HandleDirection.Left: {
direction.x = -1;
fixedPoint.x = maxX;
draggingPoint.x = minX + deltaX;
rect.w = fixedPoint.x - draggingPoint.x;
break;
}
case HandleDirection.Right: {
direction.x = 1;
fixedPoint.x = minX;
draggingPoint.x = maxX + deltaX;
rect.w = draggingPoint.x - fixedPoint.x;
break;
}
}
scale.x = rect.w / original.w;
flip.x = scale.x < 0 ? -1 : 1;
if (Math.abs(rect.w) < NOTE_MIN_WIDTH) {
rect.w = NOTE_MIN_WIDTH * flip.x;
scale.x = rect.w / original.w;
draggingPoint.x = fixedPoint.x + rect.w * direction.x;
}
rect.cx = (draggingPoint.x + fixedPoint.x) / 2;
}
const width = Math.abs(rect.w);
const height = Math.abs(rect.h);
const x = rect.cx - width / 2;
const y = rect.cy - height / 2;
_currentRect.x = x;
_currentRect.y = y;
_currentRect.width = width;
_currentRect.height = height;
const newBounds = new Map<
string,
{
bound: Bound;
path?: PointLocation[];
matrix?: DOMMatrix;
}
>();
let process: (value: SelectableProps, key: string) => void;
if (isCorner || isAll || isEdgeAndCorner) {
if (this._bounds.size === 1) {
process = (_, id) => {
newBounds.set(id, {
bound: new Bound(x, y, width, height),
});
};
} else {
const fp = fixedPoint.matrixTransform(m0);
const m2 = new DOMMatrix()
.translateSelf(fp.x, fp.y)
.rotateSelf(_rotate)
.translateSelf(-fp.x, -fp.y)
.scaleSelf(scale.x, scale.y, 1, fp.x, fp.y, 0)
.translateSelf(fp.x, fp.y)
.rotateSelf(-_rotate)
.translateSelf(-fp.x, -fp.y);
// TODO: on same rotate
process = ({ bound: { x, y, w, h }, path }, id) => {
const cx = x + w / 2;
const cy = y + h / 2;
const center = new DOMPoint(cx, cy).matrixTransform(m2);
const newWidth = Math.abs(w * scale.x);
const newHeight = Math.abs(h * scale.y);
newBounds.set(id, {
bound: new Bound(
center.x - newWidth / 2,
center.y - newHeight / 2,
newWidth,
newHeight
),
matrix: m2,
path,
});
};
}
} else {
// include notes, <---->
const m2 = new DOMMatrix().scaleSelf(
scale.x,
scale.y,
1,
fixedPoint.x,
fixedPoint.y,
0
);
process = ({ bound: { x, y, w, h }, rotate = 0, path }, id) => {
const cx = x + w / 2;
const cy = y + h / 2;
const center = new DOMPoint(cx, cy).matrixTransform(m2);
let newWidth: number;
let newHeight: number;
// TODO: determine if it is a note
if (rotate) {
const { width } = getQuadBoundWithRotation({ x, y, w, h, rotate });
const hrw = width / 2;
center.y = cy;
if (_currentRect.width <= width) {
newWidth = w * (_currentRect.width / width);
newHeight = newWidth / (w / h);
center.x = _currentRect.left + _currentRect.width / 2;
} else {
const p = (cx - hrw - _originalRect.left) / _originalRect.width;
const lx = _currentRect.left + p * _currentRect.width + hrw;
center.x = Math.max(
_currentRect.left + hrw,
Math.min(lx, _currentRect.left + _currentRect.width - hrw)
);
newWidth = w;
newHeight = h;
}
} else {
newWidth = Math.abs(w * scale.x);
newHeight = Math.abs(h * scale.y);
}
newBounds.set(id, {
bound: new Bound(
center.x - newWidth / 2,
center.y - newHeight / 2,
newWidth,
newHeight
),
matrix: m2,
path,
});
};
}
this._bounds.forEach(process);
this._onResizeMove(newBounds, this._dragDirection);
}
private _onRotate(shiftKey = false) {
const {
_originalRect: { left: minX, top: minY, right: maxX, bottom: maxY },
_dragPos: {
start: { x: startX, y: startY },
end: { x: endX, y: endY },
},
_origin: { x: centerX, y: centerY },
_rotate,
} = this;
const startRad = Math.atan2(startY - centerY, startX - centerX);
const endRad = Math.atan2(endY - centerY, endX - centerX);
let deltaRad = endRad - startRad;
// snap angle
// 15deg * n = 0, 15, 30, 45, ... 360
if (shiftKey) {
const prevRad = (_rotate * Math.PI) / 180;
let angle = prevRad + deltaRad;
angle += SHIFT_LOCKING_ANGLE / 2;
angle -= angle % SHIFT_LOCKING_ANGLE;
deltaRad = angle - prevRad;
}
const delta = (deltaRad * 180) / Math.PI;
let x = endX;
let y = endY;
if (shiftKey) {
const point = new DOMPoint(startX, startY).matrixTransform(
new DOMMatrix()
.translateSelf(centerX, centerY)
.rotateSelf(delta)
.translateSelf(-centerX, -centerY)
);
x = point.x;
y = point.y;
}
this._onRotateMove(
// center of element in suface
{ x: (minX + maxX) / 2, y: (minY + maxY) / 2 },
delta
);
this._dragPos.start = { x, y };
this._rotate += delta;
}
onPressShiftKey(pressed: boolean) {
if (!this._target) return;
if (this._locked) return;
if (this._shiftKey === pressed) return;
this._shiftKey = pressed;
const proportional = this._proportional || this._shiftKey;
if (this._rotation) {
this._onRotate(proportional);
return;
}
this._onResize(proportional);
}
updateBounds(bounds: Map<string, SelectableProps>) {
this._bounds = bounds;
}
updateRectPosition(delta: { x: number; y: number }) {
this._currentRect.x += delta.x;
this._currentRect.y += delta.y;
this._originalRect.x = this._currentRect.x;
this._originalRect.y = this._currentRect.y;
return this._originalRect;
}
updateState(
resizeMode: ResizeMode,
rotate: number,
zoom: number,
position?: { x: number; y: number },
originalRect?: DOMRect,
proportion = false
) {
this._resizeMode = resizeMode;
this._rotate = rotate;
this._zoom = zoom;
this._proportion = proportion;
if (position) {
this._currentRect.x = position.x;
this._currentRect.y = position.y;
this._originalRect.x = this._currentRect.x;
this._originalRect.y = this._currentRect.y;
}
if (originalRect) {
this._originalRect = originalRect;
this._aspectRatio = originalRect.width / originalRect.height;
this._currentRect = DOMRect.fromRect(originalRect);
}
}
}

View File

@@ -1,128 +1,65 @@
import type { IVec } from '@blocksuite/global/gfx';
import { normalizeDegAngle, Vec } from '@blocksuite/global/gfx';
import type { CursorType, StandardCursor } from '@blocksuite/std/gfx';
import type {
CursorType,
ResizeHandle,
StandardCursor,
} from '@blocksuite/std/gfx';
const rotateCursorMap: {
[key in ResizeHandle]: number;
} = {
'top-right': 0,
'bottom-right': 90,
'bottom-left': 180,
'top-left': 270,
// not used
left: 0,
right: 0,
top: 0,
bottom: 0,
};
export function generateCursorUrl(
angle = 0,
handle: ResizeHandle,
fallback: StandardCursor = 'default'
): CursorType {
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
angle = ((angle % 360) + 360) % 360;
return `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cg transform='rotate(${rotateCursorMap[handle] + angle} 16 16)'%3E%3Cpath fill='white' d='M13.7,18.5h3.9l0-1.5c0-1.4-1.2-2.6-2.6-2.6h-1.5v3.9l-5.8-5.8l5.8-5.8v3.9h2.3c3.1,0,5.6,2.5,5.6,5.6v2.3h3.9l-5.8,5.8L13.7,18.5z'/%3E%3Cpath d='M20.4,19.4v-3.2c0-2.6-2.1-4.7-4.7-4.7h-3.2l0,0V9L9,12.6l3.6,3.6v-2.6l0,0H15c1.9,0,3.5,1.6,3.5,3.5v2.4l0,0h-2.6l3.6,3.6l3.6-3.6L20.4,19.4L20.4,19.4z'/%3E%3C/g%3E%3C/svg%3E") 16 16, ${fallback}`;
}
const RESIZE_CURSORS: CursorType[] = [
'ew-resize',
'nwse-resize',
'ns-resize',
'nesw-resize',
];
export function rotateResizeCursor(angle: number): StandardCursor {
const a = Math.round(angle / (Math.PI / 4));
const cursor = RESIZE_CURSORS[a % RESIZE_CURSORS.length];
return cursor as StandardCursor;
}
export function calcAngle(target: HTMLElement, point: IVec, offset = 0) {
const rect = target
.closest('.affine-edgeless-selected-rect')
?.getBoundingClientRect();
if (!rect) {
console.error('rect not found when calc angle');
return 0;
}
const { left, top, right, bottom } = rect;
const center = Vec.med([left, top], [right, bottom]);
return normalizeDegAngle(
((Vec.angle(center, point) + offset) * 180) / Math.PI
);
}
export function calcAngleWithRotation(
target: HTMLElement,
point: IVec,
rect: DOMRect,
rotate: number
) {
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
const { left, top, right, bottom, width, height } = rect;
const size = Math.min(width, height);
const sx = size / width;
const sy = size / height;
const center = Vec.med([left, top], [right, bottom]);
const draggingPoint = [0, 0];
switch (ariaLabel) {
case 'top-left': {
draggingPoint[0] = left;
draggingPoint[1] = top;
break;
}
case 'top-right': {
draggingPoint[0] = right;
draggingPoint[1] = top;
break;
}
case 'bottom-right': {
draggingPoint[0] = right;
draggingPoint[1] = bottom;
break;
}
case 'bottom-left': {
draggingPoint[0] = left;
draggingPoint[1] = bottom;
break;
}
}
const dp = new DOMMatrix()
.translateSelf(center[0], center[1])
.rotateSelf(rotate)
.translateSelf(-center[0], -center[1])
.transformPoint(new DOMPoint(...draggingPoint));
const m = new DOMMatrix()
.translateSelf(dp.x, dp.y)
.rotateSelf(rotate)
.translateSelf(-dp.x, -dp.y)
.scaleSelf(sx, sy, 1, dp.x, dp.y, 0)
.translateSelf(dp.x, dp.y)
.rotateSelf(-rotate)
.translateSelf(-dp.x, -dp.y);
const c = new DOMPoint(...center).matrixTransform(m);
return normalizeDegAngle((Vec.angle([c.x, c.y], point) * 180) / Math.PI);
}
export function calcAngleEdgeWithRotation(target: HTMLElement, rotate: number) {
let angleWithEdge = 0;
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
switch (ariaLabel) {
case 'top': {
angleWithEdge = 270;
break;
}
case 'bottom': {
angleWithEdge = 90;
break;
}
case 'left': {
angleWithEdge = 180;
break;
}
case 'right': {
angleWithEdge = 0;
break;
}
}
return angleWithEdge + rotate;
}
export function getResizeLabel(target: HTMLElement) {
const handle = target.parentElement;
const ariaLabel = handle?.getAttribute('aria-label');
return ariaLabel;
const handleToRotateMap: {
[key in ResizeHandle]: number;
} = {
'top-left': 45,
'top-right': 135,
'bottom-right': 45,
'bottom-left': 135,
left: 0,
right: 0,
top: 90,
bottom: 90,
};
const rotateToHandleMap: {
[key: number]: StandardCursor;
} = {
0: 'ew-resize',
45: 'nwse-resize',
90: 'ns-resize',
135: 'nesw-resize',
};
export function getRotatedResizeCursor(option: {
handle: ResizeHandle;
angle: number;
}) {
const angle =
(Math.round(
(handleToRotateMap[option.handle] + ((option.angle + 360) % 360)) / 45
) %
4) *
45;
return rotateToHandleMap[angle] || 'default';
}

View File

@@ -9,13 +9,3 @@ export const ATTACHED_DISTANCE = 20;
export const SurfaceColor = '#6046FE';
export const NoteColor = '#1E96EB';
export const BlendColor = '#7D91FF';
export const AI_CHAT_BLOCK_MIN_WIDTH = 260;
export const AI_CHAT_BLOCK_MIN_HEIGHT = 160;
export const AI_CHAT_BLOCK_MAX_WIDTH = 320;
export const AI_CHAT_BLOCK_MAX_HEIGHT = 300;
export const EMBED_IFRAME_BLOCK_MIN_WIDTH = 218;
export const EMBED_IFRAME_BLOCK_MIN_HEIGHT = 44;
export const EMBED_IFRAME_BLOCK_MAX_WIDTH = 3400;
export const EMBED_IFRAME_BLOCK_MAX_HEIGHT = 2200;