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

@@ -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;