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

@@ -26,10 +26,21 @@ export type {
ExtensionDragMoveContext,
ExtensionDragStartContext,
GfxInteractivityContext,
GfxViewInteractionConfig,
ResizeConstraint,
ResizeEndContext,
ResizeHandle,
ResizeMoveContext,
ResizeStartContext,
RotateConstraint,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
SelectedContext,
} from './interactivity/index.js';
export {
GfxViewEventManager,
GfxViewInteractionExtension,
InteractivityExtension,
InteractivityIdentifier,
InteractivityManager,

View File

@@ -0,0 +1,118 @@
import { createIdentifier } from '@blocksuite/global/di';
import type { ExtensionType } from '@blocksuite/store';
import type { BlockStdScope } from '../../../scope';
import type { GfxBlockComponent } from '../../../view';
import type { GfxController, GfxModel } from '../..';
import type { GfxElementModelView } from '../../view/view';
import type {
BeforeResizeContext,
BeforeRotateContext,
ResizeConstraint,
ResizeEndContext,
ResizeMoveContext,
ResizeStartContext,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
} from '../types/view';
type ExtendedViewContext<
T extends GfxBlockComponent | GfxElementModelView,
Context,
> = {
/**
* The default function of the interaction.
* If the interaction is handled by the extension, the default function will not be executed.
* But extension can choose to call the default function by `context.default(context)` if needed.
*/
default: (context: Context) => void;
model: T['model'];
view: T;
};
type ViewInteractionHandleContext<
T extends GfxBlockComponent | GfxElementModelView,
> = {
std: BlockStdScope;
gfx: GfxController;
view: T;
model: T['model'];
/**
* Used to add an element to resize list.
* @param model
*/
add(element: GfxModel): void;
/**
* Used to remove an element from resize list.
* @param element
*/
delete(element: GfxModel): void;
};
export type GfxViewInteractionConfig<
T extends GfxBlockComponent | GfxElementModelView =
| GfxBlockComponent
| GfxElementModelView,
> = {
readonly resizeConstraint?: ResizeConstraint;
/**
* The function that will be called when the view is resized.
* You can add or delete the resize element before resize starts in this function.,
* And return handlers to customize the resize behavior.
* @param context
* @returns
*/
handleResize?: (context: ViewInteractionHandleContext<T>) => {
/**
* Called before resize starts. When this method is called, the resize elements are confirmed and will not be changed.
* You can set the resize constraint in this method.
* @param context
* @returns
*/
beforeResize?: (context: BeforeResizeContext) => void;
onResizeStart?(
context: ResizeStartContext & ExtendedViewContext<T, ResizeStartContext>
): void;
onResizeMove?(
context: ResizeMoveContext & ExtendedViewContext<T, ResizeMoveContext>
): void;
onResizeEnd?(
context: ResizeEndContext & ExtendedViewContext<T, ResizeEndContext>
): void;
};
handleRotate?: (context: ViewInteractionHandleContext<T>) => {
beforeRotate?: (context: BeforeRotateContext) => void;
onRotateStart?(
context: RotateStartContext & ExtendedViewContext<T, RotateStartContext>
): void;
onRotateMove?(
context: RotateMoveContext & ExtendedViewContext<T, RotateMoveContext>
): void;
onRotateEnd?(
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
): void;
};
};
export const GfxViewInteractionIdentifier =
createIdentifier<GfxViewInteractionConfig>('GfxViewInteraction');
export function GfxViewInteractionExtension<
T extends GfxBlockComponent | GfxElementModelView,
>(viewType: string, config: GfxViewInteractionConfig<T>): ExtensionType {
return {
setup(di) {
di.addImpl(
GfxViewInteractionIdentifier(viewType),
() => config as GfxViewInteractionConfig
);
},
};
}

View File

@@ -1,7 +1,13 @@
export type { GfxInteractivityContext } from './event.js';
export { InteractivityExtension } from './extension/base.js';
export {
type GfxViewInteractionConfig,
GfxViewInteractionExtension,
GfxViewInteractionIdentifier,
} from './extension/view.js';
export { GfxViewEventManager } from './gfx-view-event-handler.js';
export { InteractivityIdentifier, InteractivityManager } from './manager.js';
export { type ResizeHandle } from './resize/manager.js';
export type {
DragExtensionInitializeContext,
DragInitializationOption,
@@ -15,5 +21,13 @@ export type {
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
ResizeConstraint,
ResizeEndContext,
ResizeMoveContext,
ResizeStartContext,
RotateConstraint,
RotateEndContext,
RotateMoveContext,
RotateStartContext,
SelectedContext,
} from './types/view.js';

View File

@@ -1,18 +1,33 @@
import { type ServiceIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Bound, Point } from '@blocksuite/global/gfx';
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
import { signal } from '@preact/signals-core';
import type { PointerEventState } from '../../event/state/pointer.js';
import { getTopElements } from '../../utils/tree.js';
import type { GfxBlockComponent } from '../../view/index.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
import type { GfxModel } from '../model/model.js';
import type { GfxElementModelView } from '../view/view.js';
import { createInteractionContext, type SupportedEvents } from './event.js';
import {
type InteractivityActionAPI,
type InteractivityEventAPI,
InteractivityExtensionIdentifier,
} from './extension/base.js';
import {
type GfxViewInteractionConfig,
GfxViewInteractionIdentifier,
} from './extension/view.js';
import { GfxViewEventManager } from './gfx-view-event-handler.js';
import {
DEFAULT_HANDLES,
type OptionResize,
ResizeController,
type ResizeHandle,
type RotateOption,
} from './resize/manager.js';
import type { RequestElementsCloneContext } from './types/clone.js';
import type {
DragExtensionInitializeContext,
@@ -21,7 +36,11 @@ import type {
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './types/drag.js';
import type { BoxSelectionContext } from './types/view.js';
import type {
BoxSelectionContext,
ResizeConstraint,
RotateConstraint,
} from './types/view.js';
type ExtensionPointerHandler = Exclude<
SupportedEvents,
@@ -46,6 +65,11 @@ export class InteractivityManager extends GfxExtension {
});
}
activeInteraction$ = signal<null | {
type: 'move' | 'resize' | 'rotate';
elements: GfxModel[];
} | null>(null);
override unmounted(): void {
this._disposable.dispose();
this.interactExtensions.forEach(ext => {
@@ -76,6 +100,10 @@ export class InteractivityManager extends GfxExtension {
* @returns
*/
dispatchEvent(eventName: ExtensionPointerHandler, evt: PointerEventState) {
if (this.activeInteraction$.peek()) {
return;
}
const { context, preventDefaultState } = createInteractionContext(evt);
const extensions = this.interactExtensions;
@@ -247,6 +275,8 @@ export class InteractivityManager extends GfxExtension {
});
};
const onDragEnd = (event: PointerEvent) => {
this.activeInteraction$.value = null;
host.removeEventListener('pointermove', onDragMove, false);
host.removeEventListener('pointerup', onDragEnd, false);
viewportWatcher.unsubscribe();
@@ -292,6 +322,11 @@ export class InteractivityManager extends GfxExtension {
host.addEventListener('pointerup', onDragEnd, false);
};
const dragStart = () => {
this.activeInteraction$.value = {
type: 'move',
elements: context.elements,
};
internal.elements.forEach(({ view, originalBound }) => {
view.onDragStart({
currentBound: originalBound,
@@ -316,6 +351,519 @@ export class InteractivityManager extends GfxExtension {
dragStart();
}
handleElementRotate(
options: Omit<
RotateOption,
'onRotateStart' | 'onRotateEnd' | 'onRotateUpdate'
> & {
onRotateUpdate?: (payload: {
currentAngle: number;
delta: number;
}) => void;
onRotateStart?: () => void;
onRotateEnd?: () => void;
}
) {
const { rotatable, viewConfigMap, initialRotate } =
this._getViewRotateConfig(options.elements);
if (!rotatable) {
return;
}
const handler = new ResizeController({ gfx: this.gfx });
const elements = Array.from(viewConfigMap.values()).map(
config => config.view.model
) as GfxModel[];
handler.startRotate({
...options,
elements,
onRotateStart: payload => {
this.activeInteraction$.value = {
type: 'rotate',
elements,
};
options.onRotateStart?.();
payload.data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onRotateStart({
default: defaultHandlers.onRotateStart as () => void,
constraint,
model,
view,
});
});
},
onRotateUpdate: payload => {
options.onRotateUpdate?.({
currentAngle: initialRotate + payload.delta,
delta: payload.delta,
});
payload.data.forEach(
({
model,
newBound,
originalBound,
newRotate,
originalRotate,
matrix,
}) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onRotateMove({
model,
newBound,
originalBound,
newRotate,
originalRotate,
default: defaultHandlers.onRotateMove as () => void,
constraint,
view,
matrix,
});
}
);
},
onRotateEnd: payload => {
this.activeInteraction$.value = null;
options.onRotateEnd?.();
this.std.store.transact(() => {
payload.data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onRotateEnd({
default: defaultHandlers.onRotateEnd as () => void,
view,
model,
constraint,
});
});
});
},
});
}
private _getViewRotateConfig(elements: GfxModel[]) {
const deleted = new Set<GfxModel>();
const added = new Set<GfxModel>();
const del = (model: GfxModel) => {
deleted.add(model);
};
const add = (model: GfxModel) => {
added.add(model);
};
type ViewRotateHandlers = Required<
ReturnType<Required<GfxViewInteractionConfig>['handleRotate']>
>;
const viewConfigMap = new Map<
string,
{
model: GfxModel;
view: GfxElementModelView | GfxBlockComponent;
handlers: ViewRotateHandlers;
defaultHandlers: ViewRotateHandlers;
constraint: Required<RotateConstraint>;
}
>();
const addToConfigMap = (model: GfxModel) => {
const flavourOrType = 'type' in model ? model.type : model.flavour;
const interactionConfig = this.std.getOptional(
GfxViewInteractionIdentifier(flavourOrType)
);
const view = this.gfx.view.get(model);
if (!view) {
return;
}
const defaultHandlers: ViewRotateHandlers = {
beforeRotate: () => {},
onRotateStart: context => {
if (!context.constraint.rotatable) {
return;
}
if (model instanceof GfxBlockElementModel) {
if (Object.hasOwn(model.props, 'rotate')) {
// @ts-expect-error prop existence has been checked
model.stash('rotate');
model.stash('xywh');
}
} else {
model.stash('rotate');
model.stash('xywh');
}
},
onRotateEnd: context => {
if (!context.constraint.rotatable) {
return;
}
if (model instanceof GfxBlockElementModel) {
if (Object.hasOwn(model.props, 'rotate')) {
// @ts-expect-error prop existence has been checked
model.pop('rotate');
model.pop('xywh');
}
} else {
model.pop('rotate');
model.pop('xywh');
}
},
onRotateMove: context => {
if (!context.constraint.rotatable) {
return;
}
const { newBound, newRotate } = context;
model.rotate = newRotate;
model.xywh = newBound.serialize();
},
};
const handlers = interactionConfig?.handleRotate?.({
std: this.std,
gfx: this.gfx,
view,
model,
delete: del,
add,
});
viewConfigMap.set(model.id, {
model,
view,
defaultHandlers,
handlers: Object.assign({}, defaultHandlers, handlers ?? {}),
constraint: {
rotatable: true,
},
});
};
elements.forEach(addToConfigMap);
deleted.forEach(model => {
if (viewConfigMap.has(model.id)) {
viewConfigMap.delete(model.id);
}
});
added.forEach(model => {
if (viewConfigMap.has(model.id)) {
return;
}
addToConfigMap(model);
});
const views = Array.from(viewConfigMap.values().map(item => item.view));
let rotatable = true;
viewConfigMap.forEach(config => {
const handlers = config.handlers;
handlers.beforeRotate({
set: (newConstraint: RotateConstraint) => {
Object.assign(config.constraint, newConstraint);
rotatable = rotatable && config.constraint.rotatable;
},
elements: views,
});
});
return {
initialRotate: views.length > 1 ? 0 : (views[0]?.model.rotate ?? 0),
rotatable,
viewConfigMap,
};
}
private _getViewResizeConfig(elements: GfxModel[]) {
const deleted = new Set<GfxModel>();
const added = new Set<GfxModel>();
const del = (model: GfxModel) => {
deleted.add(model);
};
const add = (model: GfxModel) => {
added.add(model);
};
type ViewResizeHandlers = Required<
ReturnType<Required<GfxViewInteractionConfig>['handleResize']>
>;
const viewConfigMap = new Map<
string,
{
model: GfxModel;
view: GfxElementModelView | GfxBlockComponent;
constraint: Required<ResizeConstraint>;
handlers: ViewResizeHandlers;
defaultHandlers: ViewResizeHandlers;
}
>();
const addToConfigMap = (model: GfxModel) => {
const flavourOrType = 'type' in model ? model.type : model.flavour;
const interactionConfig = this.std.getOptional(
GfxViewInteractionIdentifier(flavourOrType)
);
const view = this.gfx.view.get(model);
if (!view) {
return;
}
const defaultHandlers: ViewResizeHandlers = {
beforeResize: () => {},
onResizeStart: () => {
model.stash('xywh');
},
onResizeEnd: () => {
model.pop('xywh');
},
onResizeMove: context => {
const { newBound, constraint } = context;
const { minWidth, minHeight, maxWidth, maxHeight } = constraint;
newBound.w = clamp(newBound.w, minWidth, maxWidth);
newBound.h = clamp(newBound.h, minHeight, maxHeight);
model.xywh = newBound.serialize();
},
};
const handlers = interactionConfig?.handleResize?.({
std: this.std,
gfx: this.gfx,
view,
model,
delete: del,
add,
});
viewConfigMap.set(model.id, {
model,
view,
constraint: {
lockRatio: false,
allowedHandlers: DEFAULT_HANDLES,
minHeight: 2,
minWidth: 2,
maxHeight: 5000000,
maxWidth: 5000000,
...interactionConfig?.resizeConstraint,
},
defaultHandlers,
handlers: Object.assign({}, defaultHandlers, handlers ?? {}),
});
};
elements.forEach(addToConfigMap);
deleted.forEach(model => {
if (viewConfigMap.has(model.id)) {
viewConfigMap.delete(model.id);
}
});
added.forEach(model => {
if (viewConfigMap.has(model.id)) {
return;
}
addToConfigMap(model);
});
const views = Array.from(viewConfigMap.values().map(item => item.view));
let allowedHandlers = new Set(DEFAULT_HANDLES);
viewConfigMap.forEach(config => {
const currConstraint: Required<ResizeConstraint> = config.constraint;
config.handlers.beforeResize({
set: (newConstraint: ResizeConstraint) => {
Object.assign(currConstraint, newConstraint);
},
elements: views,
});
config.constraint = currConstraint;
const currentAllowedHandlers = new Set(currConstraint.allowedHandlers);
allowedHandlers.forEach(h => {
if (!currentAllowedHandlers.has(h)) {
allowedHandlers.delete(h);
}
});
});
return {
allowedHandlers: Array.from(allowedHandlers) as ResizeHandle[],
viewConfigMap,
};
}
getRotateConfig(options: { elements: GfxModel[] }) {
return this._getViewRotateConfig(options.elements);
}
getResizeHandlers(options: { elements: GfxModel[] }) {
return this._getViewResizeConfig(options.elements).allowedHandlers;
}
handleElementResize(
options: Omit<
OptionResize,
'lockRatio' | 'onResizeStart' | 'onResizeEnd' | 'onResizeUpdate'
> & {
onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizeUpdate?: (payload: {
lockRatio: boolean;
scaleX: number;
scaleY: number;
exceed: {
w: boolean;
h: boolean;
};
}) => void;
}
) {
const { viewConfigMap, allowedHandlers } = this._getViewResizeConfig(
options.elements
);
if (!allowedHandlers.includes(options.handle)) {
return;
}
const { handle } = options;
const controller = new ResizeController({ gfx: this.gfx });
const elements = Array.from(viewConfigMap.values()).map(
config => config.view.model
) as GfxModel[];
let lockRatio = false;
viewConfigMap.forEach(config => {
const { lockRatio: lockRatioConfig } = config.constraint;
lockRatio =
lockRatio ||
lockRatioConfig === true ||
(Array.isArray(lockRatioConfig) && lockRatioConfig.includes(handle));
});
controller.startResize({
...options,
lockRatio,
elements,
onResizeStart: ({ data }) => {
this.activeInteraction$.value = {
type: 'resize',
elements,
};
options.onResizeStart?.();
data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onResizeStart({
handle,
default: defaultHandlers.onResizeStart as () => void,
constraint,
model,
view,
});
});
},
onResizeUpdate: ({ data, scaleX, scaleY, lockRatio }) => {
const exceed = {
w: false,
h: false,
};
data.forEach(
({ model, newBound, originalBound, lockRatio, matrix }) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onResizeMove({
model,
newBound,
originalBound,
handle,
default: defaultHandlers.onResizeMove as () => void,
constraint,
view,
lockRatio,
matrix,
});
exceed.w =
exceed.w ||
model.w === constraint.minWidth ||
model.w === constraint.maxWidth;
exceed.h =
exceed.h ||
model.h === constraint.minHeight ||
model.h === constraint.maxHeight;
}
);
options.onResizeUpdate?.({ scaleX, scaleY, lockRatio, exceed });
},
onResizeEnd: ({ data }) => {
this.activeInteraction$.value = null;
options.onResizeEnd?.();
this.std.store.transact(() => {
data.forEach(({ model }) => {
if (!viewConfigMap.has(model.id)) {
return;
}
const { handlers, defaultHandlers, view, constraint } =
viewConfigMap.get(model.id)!;
handlers.onResizeEnd({
default: defaultHandlers.onResizeEnd as () => void,
view,
model,
constraint,
handle,
});
});
});
},
});
}
requestElementClone(options: RequestElementsCloneContext) {
const extensions = this.interactExtensions;

View File

@@ -0,0 +1,558 @@
import {
Bound,
getCommonBoundWithRotation,
type IVec,
} from '@blocksuite/global/gfx';
import type { GfxController } from '../..';
import type { GfxModel } from '../../model/model';
export type ResizeHandle =
| 'top-left'
| 'top'
| 'top-right'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left';
export const DEFAULT_HANDLES: ResizeHandle[] = [
'top-left',
'top-right',
'bottom-left',
'bottom-right',
'left',
'right',
'top',
'bottom',
];
interface ElementInitialSnapshot {
x: number;
y: number;
w: number;
h: number;
rotate: number;
}
export interface OptionResize {
elements: GfxModel[];
handle: ResizeHandle;
lockRatio: boolean;
event: PointerEvent;
onResizeUpdate: (payload: {
lockRatio: boolean;
scaleX: number;
scaleY: number;
data: {
model: GfxModel;
originalBound: Bound;
newBound: Bound;
lockRatio: boolean;
matrix: DOMMatrix;
}[];
}) => void;
onResizeStart?: (payload: { data: { model: GfxModel }[] }) => void;
onResizeEnd?: (payload: { data: { model: GfxModel }[] }) => void;
}
export type RotateOption = {
elements: GfxModel[];
event: PointerEvent;
onRotateUpdate: (payload: {
delta: number;
data: {
model: GfxModel;
newBound: Bound;
originalBound: Bound;
originalRotate: number;
newRotate: number;
matrix: DOMMatrix;
}[];
}) => void;
onRotateStart?: (payload: { data: { model: GfxModel }[] }) => void;
onRotateEnd?: (payload: { data: { model: GfxModel }[] }) => void;
};
export class ResizeController {
private readonly gfx: GfxController;
get host() {
return this.gfx.std.host;
}
constructor(option: { gfx: GfxController }) {
this.gfx = option.gfx;
}
startResize(options: OptionResize) {
const {
elements,
handle,
lockRatio,
onResizeStart,
onResizeUpdate,
onResizeEnd,
event,
} = options;
const originals: ElementInitialSnapshot[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
h: el.h,
rotate: el.rotate,
}));
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX,
event.clientY,
]);
const onPointerMove = (e: PointerEvent) => {
const currPt = this.gfx.viewport.toModelCoordFromClientCoord([
e.clientX,
e.clientY,
]);
const shouldLockRatio = lockRatio || e.shiftKey;
if (elements.length === 1) {
this.resizeSingle(
originals[0],
elements[0],
shouldLockRatio,
startPt,
currPt,
handle,
onResizeUpdate
);
} else {
this.resizeMulti(
originals,
elements,
handle,
currPt,
startPt,
onResizeUpdate
);
}
};
onResizeStart?.({ data: elements.map(model => ({ model })) });
const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp);
onResizeEnd?.({ data: elements.map(model => ({ model })) });
};
this.host.addEventListener('pointermove', onPointerMove);
this.host.addEventListener('pointerup', onPointerUp);
}
private resizeSingle(
orig: ElementInitialSnapshot,
model: GfxModel,
lockRatio: boolean,
startPt: IVec,
currPt: IVec,
handle: ResizeHandle,
updateCallback: OptionResize['onResizeUpdate']
) {
const { xSign, ySign } = this.getHandleSign(handle);
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 currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]), true);
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]), 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] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => {
const localP = toLocal(p, false);
const scaledP = localP.matrixTransform(scaleM);
return toModel(scaledP);
});
const center = {
x:
Math.min(visualTopLeft.x, visualBottomRight.x) +
Math.abs(visualBottomRight.x - visualTopLeft.x) / 2,
y:
Math.min(visualTopLeft.y, visualBottomRight.y) +
Math.abs(visualBottomRight.y - visualTopLeft.y) / 2,
};
const restoreM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(-orig.rotate)
.translate(-center.x, -center.y);
// only used to provide the matrix information
const finalM = restoreM
.multiply(toLocalRotatedM.inverse())
.multiply(scaleM)
.multiply(toLocalM);
const [topLeft, bottomRight] = [visualTopLeft, visualBottomRight].map(p => {
return p.matrixTransform(restoreM);
});
updateCallback({
lockRatio,
scaleX,
scaleY,
data: [
{
model: model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(
Math.min(topLeft.x, bottomRight.x),
Math.min(bottomRight.y, topLeft.y),
Math.abs(bottomRight.x - topLeft.x),
Math.abs(bottomRight.y - topLeft.y)
),
lockRatio: lockRatio,
matrix: finalM,
},
],
});
}
private resizeMulti(
originals: ElementInitialSnapshot[],
elements: GfxModel[],
handle: ResizeHandle,
currPt: IVec,
startPt: IVec,
updateCallback: OptionResize['onResizeUpdate']
) {
const commonBound = getCommonBoundWithRotation(originals);
const { xSign, ySign } = this.getHandleSign(handle);
const pivot = new DOMPoint(
commonBound.x + ((-xSign + 1) / 2) * commonBound.w,
commonBound.y + ((-ySign + 1) / 2) * commonBound.h
);
const toLocalM = new DOMMatrix().translate(-pivot.x, -pivot.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const currPtLocal = toLocal(new DOMPoint(currPt[0], currPt[1]));
const handleLocal = toLocal(new DOMPoint(startPt[0], startPt[1]));
let scaleX = xSign
? (xSign * (currPtLocal.x - handleLocal.x) + commonBound.w) /
commonBound.w
: 1;
let scaleY = ySign
? (ySign * (currPtLocal.y - handleLocal.y) + commonBound.h) /
commonBound.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 orig = originals[i];
const finalM = new DOMMatrix()
.multiply(toLocalM.inverse())
.multiply(scaleM)
.multiply(toLocalM);
const [topLeft, bottomRight] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => {
return p.matrixTransform(finalM);
});
const newBound = new Bound(
Math.min(topLeft.x, bottomRight.x),
Math.min(bottomRight.y, topLeft.y),
Math.abs(bottomRight.x - topLeft.x),
Math.abs(bottomRight.y - topLeft.y)
);
return {
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound,
lockRatio: true,
matrix: finalM,
};
});
updateCallback({ lockRatio: true, scaleX, scaleY, data });
}
startRotate(option: RotateOption) {
const { event, elements, onRotateUpdate } = option;
const originals: ElementInitialSnapshot[] = elements.map(el => ({
x: el.x,
y: el.y,
w: el.w,
h: el.h,
rotate: el.rotate,
}));
const startPt = this.gfx.viewport.toModelCoordFromClientCoord([
event.clientX,
event.clientY,
]);
const onPointerMove = (e: PointerEvent) => {
const currentPt = this.gfx.viewport.toModelCoordFromClientCoord([
e.clientX,
e.clientY,
]);
const snap = e.shiftKey;
if (elements.length > 1) {
this.rotateMulti({
origs: originals,
models: elements,
startPt,
currentPt,
snap,
onRotateUpdate,
});
} else {
this.rotateSingle({
orig: originals[0],
model: elements[0],
startPt,
currentPt,
snap,
onRotateUpdate,
});
}
};
const onPointerUp = () => {
this.host.removeEventListener('pointermove', onPointerMove);
this.host.removeEventListener('pointerup', onPointerUp);
this.host.removeEventListener('pointercancel', onPointerUp);
option.onRotateEnd?.({ data: elements.map(model => ({ model })) });
};
option.onRotateStart?.({ data: elements.map(model => ({ model })) });
this.host.addEventListener('pointermove', onPointerMove, false);
this.host.addEventListener('pointerup', onPointerUp, false);
this.host.addEventListener('pointercancel', onPointerUp, false);
}
private getNormalizedAngle(y: number, x: number) {
let angle = Math.atan2(y, x);
if (angle < 0) {
angle += 2 * Math.PI;
}
return (angle * 180) / Math.PI;
}
private toNormalizedAngle(angle: number) {
if (angle < 0) {
angle += 360;
}
return Math.round(angle) % 360;
}
private rotateSingle(option: {
orig: ElementInitialSnapshot;
model: GfxModel;
startPt: IVec;
currentPt: IVec;
snap: boolean;
onRotateUpdate?: RotateOption['onRotateUpdate'];
}) {
const { orig, model, startPt, currentPt, snap, onRotateUpdate } = option;
const center = {
x: orig.x + orig.w / 2,
y: orig.y + orig.h / 2,
};
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
const startAngle = this.getNormalizedAngle(v0.y, v0.x),
endAngle = this.getNormalizedAngle(v1.y, v1.x);
const deltaDeg = endAngle - startAngle;
const rotatedAngle = orig.rotate + deltaDeg;
const targetRotate = this.toNormalizedAngle(
snap
? Math.round((rotatedAngle % 15) / 15) * 15 +
Math.floor(rotatedAngle / 15) * 15
: rotatedAngle
);
// only used to provide the matrix information
const rotateM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(targetRotate - orig.rotate)
.translate(-center.x, -center.y);
onRotateUpdate?.({
delta: deltaDeg,
data: [
{
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(orig.x, orig.y, orig.w, orig.h),
originalRotate: orig.rotate,
newRotate: targetRotate,
matrix: rotateM,
},
],
});
}
private rotateMulti(option: {
origs: ElementInitialSnapshot[];
models: GfxModel[];
startPt: IVec;
currentPt: IVec;
snap: boolean;
onRotateUpdate?: RotateOption['onRotateUpdate'];
}) {
const { models, startPt, currentPt, onRotateUpdate } = option;
const commonBound = getCommonBoundWithRotation(option.origs);
const center = {
x: commonBound.x + commonBound.w / 2,
y: commonBound.y + commonBound.h / 2,
};
const toLocalM = new DOMMatrix().translate(-center.x, -center.y);
const toLocal = (p: DOMPoint) => p.matrixTransform(toLocalM);
const v0 = toLocal(new DOMPoint(startPt[0], startPt[1])),
v1 = toLocal(new DOMPoint(currentPt[0], currentPt[1]));
const a0 = this.getNormalizedAngle(v0.y, v0.x),
a1 = this.getNormalizedAngle(v1.y, v1.x);
const deltaDeg = a1 - a0;
const rotateM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(deltaDeg)
.translate(-center.x, -center.y);
const toRotatedPoint = (p: DOMPoint) => p.matrixTransform(rotateM);
onRotateUpdate?.({
delta: deltaDeg,
data: models.map((model, index) => {
const orig = option.origs[index];
const center = {
x: orig.x + orig.w / 2,
y: orig.y + orig.h / 2,
};
const visualM = new DOMMatrix()
.translate(center.x, center.y)
.rotate(orig.rotate)
.translate(-center.x, -center.y);
const toVisual = (p: DOMPoint) => p.matrixTransform(visualM);
const [rotatedVisualLeftTop, rotatedVisualBottomRight] = [
new DOMPoint(orig.x, orig.y),
new DOMPoint(orig.x + orig.w, orig.y + orig.h),
].map(p => toRotatedPoint(toVisual(p)));
const newCenter = {
x:
Math.min(rotatedVisualLeftTop.x, rotatedVisualBottomRight.x) +
Math.abs(rotatedVisualBottomRight.x - rotatedVisualLeftTop.x) / 2,
y:
Math.min(rotatedVisualLeftTop.y, rotatedVisualBottomRight.y) +
Math.abs(rotatedVisualBottomRight.y - rotatedVisualLeftTop.y) / 2,
};
const newRotated = this.toNormalizedAngle(orig.rotate + deltaDeg);
const finalM = new DOMMatrix()
.translate(newCenter.x, newCenter.y)
.rotate(-newRotated)
.translate(-newCenter.x, -newCenter.y)
.multiply(rotateM)
.multiply(visualM);
const topLeft = rotatedVisualLeftTop.matrixTransform(
new DOMMatrix()
.translate(newCenter.x, newCenter.y)
.rotate(-newRotated)
.translate(-newCenter.x, -newCenter.y)
);
return {
model,
originalBound: new Bound(orig.x, orig.y, orig.w, orig.h),
newBound: new Bound(topLeft.x, topLeft.y, orig.w, orig.h),
originalRotate: orig.rotate,
newRotate: newRotated,
matrix: finalM,
};
}),
});
}
private getHandleSign(handle: ResizeHandle) {
switch (handle) {
case 'top-left':
return { xSign: -1, ySign: -1 };
case 'top':
return { xSign: 0, ySign: -1 };
case 'top-right':
return { xSign: 1, ySign: -1 };
case 'right':
return { xSign: 1, ySign: 0 };
case 'bottom-right':
return { xSign: 1, ySign: 1 };
case 'bottom':
return { xSign: 0, ySign: 1 };
case 'bottom-left':
return { xSign: -1, ySign: 1 };
case 'left':
return { xSign: -1, ySign: 0 };
default:
return { xSign: 0, ySign: 0 };
}
}
}

View File

@@ -3,6 +3,7 @@ import type { Bound, IBound, IPoint } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../../view/element/gfx-block-component.js';
import type { GfxModel } from '../../model/model.js';
import type { GfxElementModelView } from '../../view/view.js';
import type { ResizeHandle } from '../resize/manager.js';
export type DragStartContext = {
/**
@@ -34,6 +35,97 @@ export type DragMoveContext = DragStartContext & {
export type DragEndContext = DragMoveContext;
export type ResizeConstraint = {
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
allowedHandlers?: ResizeHandle[];
/**
* Whether to lock the aspect ratio of the element when resizing.
* If the value is an array, it will only lock the aspect ratio when resizing the specified handles.
*/
lockRatio?: boolean | ResizeHandle[];
};
export type BeforeResizeContext = {
/**
* The elements that will be resized
*/
elements: (GfxBlockComponent | GfxElementModelView)[];
/**
* Set the constraint before resize starts.
*/
set: (constraint: ResizeConstraint) => void;
};
export type ResizeStartContext = {
/**
* The handle that is used to resize the element
*/
handle: ResizeHandle;
/**
* The resize constraint.
*/
constraint: Readonly<Required<ResizeConstraint>>;
};
export type ResizeMoveContext = ResizeStartContext & {
/**
* The element bound when resize starts
*/
originalBound: Bound;
newBound: Bound;
/**
* The matrix that used to transform the element.
*/
matrix: DOMMatrix;
lockRatio: boolean;
};
export type ResizeEndContext = ResizeStartContext;
export type RotateConstraint = {
rotatable?: boolean;
};
export type BeforeRotateContext = {
/**
* The elements that will be rotated
*/
elements: (GfxBlockComponent | GfxElementModelView)[];
/**
* Set the constraint before rotate starts.
*/
set: (constraint: RotateConstraint) => void;
};
export type RotateStartContext = {
constraint: Readonly<Required<RotateConstraint>>;
};
export type RotateMoveContext = RotateStartContext & {
newBound: Bound;
originalBound: Bound;
newRotate: number;
originalRotate: number;
matrix: DOMMatrix;
};
export type RotateEndContext = RotateStartContext;
export type SelectedContext = {
/**
* The selected state of the element
@@ -79,8 +171,6 @@ export type GfxViewTransformInterface = {
onDragStart: (context: DragStartContext) => void;
onDragMove: (context: DragMoveContext) => void;
onDragEnd: (context: DragEndContext) => void;
onRotate: (context: {}) => void;
onResize: (context: {}) => void;
/**
* When the element is selected by the pointer

View File

@@ -116,7 +116,19 @@ export class GfxBlockElementModel<
*/
responseExtension: [number, number] = [0, 0];
rotate = 0;
get rotate() {
if ('rotate' in this.props) {
return this.props.rotate as number;
}
return 0;
}
set rotate(rotate: number) {
if ('rotate' in this.props) {
this.props.rotate = rotate;
}
}
get deserializedXYWH() {
if (this._cacheDeserKey !== this.xywh || !this._cacheDeserXYWH) {

View File

@@ -224,10 +224,6 @@ export class GfxElementModelView<
onBoxSelected(_: BoxSelectionContext): boolean | void {}
onResize = () => {};
onRotate = () => {};
/**
* Called when the view is destroyed.
* Override this method requires calling `super.onDestroyed()`.

View File

@@ -11,7 +11,7 @@ import type {
GfxViewTransformInterface,
SelectedContext,
} from '../../gfx/interactivity/index.js';
import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
import { SurfaceSelection } from '../../selection/index.js';
import { BlockComponent } from './block-component.js';
@@ -116,10 +116,6 @@ export abstract class GfxBlockComponent<
onBoxSelected(_: BoxSelectionContext) {}
onRotate() {}
onResize() {}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
@@ -236,10 +232,6 @@ export function toGfxBlockComponent<
onBoxSelected(_: BoxSelectionContext) {}
onRotate() {}
onResize() {}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}