feat: add ElementTransformManager for edgeless element basic manipulation (#10824)

### Overview:
We've been working with some legacy code in the default-tool and edgeless-selected-rect modules, which are responsible for fundamental operations like moving, resizing, and rotating elements. Currently, these operations are hardcoded, making it challenging to extend functionalities without diving deep into the code.

### What's Changing:
Introducing `ElementTransformManager` to streamline the handling of basic transformations (move, resize, rotate) while allowing the business logic to dictate when these actions occur.

Providing two ways to extend the transformations behaviour:
- Extends inside element view definition: Elements can decide how to handle move/resize events, such as enforcing size constraints.
- Extension mechanism provided by this manager: Adjust or completely override default drag behaviors, like snapping elements into alignment.

### Code Examples:
Delegate element movement to TransformManager:
```typescript
class DefaultTool {
  override dragStart(event) {
    if(this.dragType === DragType.ContentMoving) {
      const transformManager = this.std.get(TransformManagerIdentifier);
      transformManager.startDrag({ selectedElements, event });
    }
  }
}
```

 Enforce minimum width inside view definition:
```typescript
class EdgelessNoteBlock extends GfxBlockComponent {
  onResizeDelta({ dw, dh }) {
    const bound = this.model.elementBound;
    bound.w = Math.min(MAX_WIDTH, bound.w + dw);
    bound.h = Math.min(MAX_HEIGHT, bound.h + dh);
    this.model.xywh = bound.serialize();
  }
}
```

Use extension to implement element snapping:
```typescript
import { TransformerExtension } from '@blocksuite/std/gfx';

// Just extends the TransformerExtension
class SnapManager extends TransformerExtension {
  static override key = 'snap-manager';
  onDragInitialize() {
    return {
      onDragMove(context) {
        const { dx, dy } = this.getAlignmentMoveDistance(context.elements);
        context.dx = dx;
        context.dy = dy;
      }
    }
  }
}
```

### Others

The migration will be divided into several PRs. This PR mostly focus on refactoring elements movement part of `default-tool`.
- Delegate elements movement to `TransformManager`
- Rewrite the default tool extension into `TransformManager` extension
- Add drag handler interface to gfx view (both `GfxBlockComponent` and `GfxElementModelView`) to allow element to define how it gonna react on drag
This commit is contained in:
doouding
2025-03-19 15:30:06 +00:00
parent 89a0880bb3
commit 1c8d25bc29
23 changed files with 864 additions and 453 deletions

View File

@@ -181,6 +181,15 @@ export class GfxController extends LifeCycleWatcher {
return last(picked) ?? null;
}
/**
* Get the top element in the given point.
* If the element is in a group, the group will be returned.
* If the group is currently selected, the child element will be returned.
* @param x
* @param y
* @param options
* @returns
*/
getElementInGroup(
x: number,
y: number,

View File

@@ -0,0 +1,73 @@
import type { Bound } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../view';
import type { GfxModel } from '../model/model';
import type { GfxElementModelView } from '../view/view';
export type DragInitializationOption = {
movingElements: GfxModel[];
event: PointerEvent | MouseEvent;
onDragEnd?: () => void;
};
export type DragExtensionInitializeContext = {
/**
* The elements that are being dragged.
* The extension can modify this array to add or remove dragging elements.
*/
elements: GfxModel[];
/**
* Prevent the default drag behavior. The following drag events will not be triggered.
*/
preventDefault: () => void;
/**
* The start position of the drag in model space.
*/
dragStartPos: Readonly<{
x: number;
y: number;
}>;
};
export type ExtensionBaseEvent = {
/**
* The elements that respond to the event.
*/
elements: {
view: GfxBlockComponent | GfxElementModelView;
originalBound: Bound;
model: GfxModel;
}[];
/**
* The mouse event
*/
event: PointerEvent;
/**
* The start position of the drag in model space.
*/
dragStartPos: Readonly<{
x: number;
y: number;
}>;
/**
* The last position of the drag in model space.
*/
dragLastPos: Readonly<{
x: number;
y: number;
}>;
};
export type ExtensionDragStartContext = ExtensionBaseEvent;
export type ExtensionDragMoveContext = ExtensionBaseEvent & {
dx: number;
dy: number;
};
export type ExtensionDragEndContext = ExtensionDragMoveContext;

View File

@@ -0,0 +1,63 @@
import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import type { PointerEventState } from '../../../event';
import type { GfxElementModelView } from '../../view/view';
import { TransformExtension } from '../transform-manager';
export class CanvasEventHandler extends TransformExtension {
static override key = 'canvas-event-handler';
private _currentStackedElm: GfxElementModelView[] = [];
private _callInReverseOrder(
callback: (view: GfxElementModelView) => void,
arr = this._currentStackedElm
) {
for (let i = arr.length - 1; i >= 0; i--) {
const view = arr[i];
callback(view);
}
}
override click(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('click', _evt);
}
override dblClick(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
}
override pointerDown(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
}
override pointerMove(_evt: PointerEventState): void {
const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y);
const hoveredElmViews = this.gfx.grid
.search(new Bound(x, y, 1, 1), {
filter: ['canvas', 'local'],
})
.map(model => this.gfx.view.get(model)) as GfxElementModelView[];
const currentStackedViews = new Set(this._currentStackedElm);
const visited = new Set<GfxElementModelView>();
this._callInReverseOrder(view => {
if (currentStackedViews.has(view)) {
visited.add(view);
view.dispatch('pointermove', _evt);
} else {
view.dispatch('pointerenter', _evt);
}
}, hoveredElmViews);
this._callInReverseOrder(
view => !visited.has(view) && view.dispatch('pointerleave', _evt)
);
this._currentStackedElm = hoveredElmViews;
}
override pointerUp(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
}
}

View File

@@ -0,0 +1,304 @@
import {
type Container,
createIdentifier,
type ServiceIdentifier,
} from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Bound, Point } from '@blocksuite/global/gfx';
import { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../event/state/pointer.js';
import { type GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxControllerIdentifier } from '../identifiers.js';
import { type SupportedEvent } from '../view/view.js';
import type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './drag.js';
type ExtensionPointerHandler = Exclude<
SupportedEvent,
'pointerleave' | 'pointerenter'
>;
export const TransformManagerIdentifier = GfxExtensionIdentifier(
'element-transform-manager'
) as ServiceIdentifier<ElementTransformManager>;
const CAMEL_CASE_MAP: {
[key in ExtensionPointerHandler]: keyof Pick<
TransformExtension,
'click' | 'dblClick' | 'pointerDown' | 'pointerMove' | 'pointerUp'
>;
} = {
click: 'click',
dblclick: 'dblClick',
pointerdown: 'pointerDown',
pointermove: 'pointerMove',
pointerup: 'pointerUp',
};
export class ElementTransformManager extends GfxExtension {
static override key = 'element-transform-manager';
private readonly _disposable = new DisposableGroup();
override mounted(): void {
//
}
override unmounted(): void {
this._disposable.dispose();
}
get transformExtensions() {
return this.std.provider.getAll(TransformExtensionIdentifier);
}
get keyboard() {
return this.gfx.keyboard;
}
private _safeExecute(fn: () => void, errorMessage: string) {
try {
fn();
} catch (e) {
console.error(errorMessage, e);
}
}
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const transformExtensions = this.transformExtensions;
transformExtensions.forEach(ext => {
const handlerMethodName = CAMEL_CASE_MAP[eventName];
if (ext[handlerMethodName]) {
this._safeExecute(() => {
ext[handlerMethodName](evt);
}, `Error while executing extension \`${handlerMethodName}\` handler`);
}
});
}
initializeDrag(options: DragInitializationOption) {
let cancelledByExt = false;
const context: DragExtensionInitializeContext = {
/**
* The elements that are being dragged
*/
elements: options.movingElements,
preventDefault: () => {
cancelledByExt = true;
},
dragStartPos: Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([
options.event.x,
options.event.y,
])
),
};
const extension = this.transformExtensions;
const activeExtensionHandlers = Array.from(
extension.values().map(ext => {
return ext.onDragInitialize(context);
})
);
if (cancelledByExt) {
activeExtensionHandlers.forEach(handler => handler.clear?.());
return;
}
const host = this.std.host;
const { event } = options;
const internal = {
elements: context.elements.map(model => {
return {
view: this.gfx.view.get(model)!,
originalBound: Bound.deserialize(model.xywh),
model: model,
};
}),
dragStartPos: Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
),
};
let dragLastPos = internal.dragStartPos;
let lastEvent = event;
const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => {
onDragMove(lastEvent as PointerEvent);
});
const onDragMove = (event: PointerEvent) => {
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
const moveContext: ExtensionDragMoveContext = {
...internal,
event,
dragLastPos,
dx: dragLastPos.x - internal.dragStartPos.x,
dy: dragLastPos.y - internal.dragStartPos.y,
};
// If shift key is pressed, restrict the movement to one direction
if (this.keyboard.shiftKey$.peek()) {
const angle = Math.abs(Math.atan2(moveContext.dy, moveContext.dx));
const direction =
angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'dy' : 'dx';
moveContext[direction] = 0;
}
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragMove?.(moveContext)
);
}, 'Error while executing extension `onDragMove`');
internal.elements.forEach(element => {
const { view, originalBound } = element;
view.onDragMove({
currentBound: originalBound,
dx: moveContext.dx,
dy: moveContext.dy,
elements: internal.elements,
});
});
};
const onDragEnd = (event: PointerEvent) => {
host.removeEventListener('pointermove', onDragMove, false);
host.removeEventListener('pointerup', onDragEnd, false);
viewportWatcher.unsubscribe();
dragLastPos = Point.from(
this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y])
);
const endContext: ExtensionDragEndContext = {
...internal,
event,
dragLastPos,
dx: dragLastPos.x - internal.dragStartPos.x,
dy: dragLastPos.y - internal.dragStartPos.y,
};
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragEnd?.(endContext)
);
}, 'Error while executing extension `onDragEnd` handler');
this.std.store.transact(() => {
internal.elements.forEach(element => {
const { view, originalBound } = element;
view.onDragEnd({
currentBound: originalBound.moveDelta(endContext.dx, endContext.dy),
dx: endContext.dx,
dy: endContext.dy,
elements: internal.elements,
});
});
});
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler => handler.clear?.());
}, 'Error while executing extension `clear` handler');
options.onDragEnd?.();
};
const listenEvent = () => {
host.addEventListener('pointermove', onDragMove, false);
host.addEventListener('pointerup', onDragEnd, false);
};
const dragStart = () => {
internal.elements.forEach(({ view, originalBound }) => {
view.onDragStart({
currentBound: originalBound,
elements: internal.elements,
});
});
const dragStartContext: ExtensionDragStartContext = {
...internal,
event: event as PointerEvent,
dragLastPos,
};
this._safeExecute(() => {
activeExtensionHandlers.forEach(handler =>
handler.onDragStart?.(dragStartContext)
);
}, 'Error while executing extension `onDragStart` handler');
};
listenEvent();
dragStart();
}
}
export const TransformExtensionIdentifier =
createIdentifier<TransformExtension>('element-transform-extension');
export class TransformExtension extends Extension {
static key: string;
get std() {
return this.gfx.std;
}
constructor(protected readonly gfx: GfxController) {
super();
}
mounted() {}
unmounted() {}
click(_: PointerEventState) {}
dblClick(_: PointerEventState) {}
pointerDown(_: PointerEventState) {}
pointerMove(_: PointerEventState) {}
pointerUp(_: PointerEventState) {}
onDragInitialize(_: DragExtensionInitializeContext): {
onDragStart?: (context: ExtensionDragStartContext) => void;
onDragMove?: (context: ExtensionDragMoveContext) => void;
onDragEnd?: (context: ExtensionDragEndContext) => void;
clear?: () => void;
} {
return {};
}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'key is not defined in the TransformExtension'
);
}
di.add(
this as unknown as { new (gfx: GfxController): TransformExtension },
[GfxControllerIdentifier]
);
di.addImpl(TransformExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
}

View File

@@ -0,0 +1,43 @@
import type { Bound } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../view';
import type { GfxModel } from '../model/model';
import type { GfxElementModelView } from '../view/view';
export type DragStartContext = {
/**
* The elements that are being dragged
*/
elements: {
view: GfxBlockComponent | GfxElementModelView;
originalBound: Bound;
model: GfxModel;
}[];
/**
* The bound of element when drag starts
*/
currentBound: Bound;
};
export type DragMoveContext = DragStartContext & {
/**
* The delta x of current drag position compared to the start position in model coordinate.
*/
dx: number;
/**
* The delta y of current drag position compared to the start position in model coordinate.
*/
dy: number;
};
export type DragEndContext = DragMoveContext;
export type GfxViewTransformInterface = {
onDragStart: (context: DragStartContext) => void;
onDragMove: (context: DragMoveContext) => void;
onDragEnd: (context: DragEndContext) => void;
onRotate: (context: {}) => void;
onResize: (context: {}) => void;
};

View File

@@ -12,6 +12,25 @@ export {
} from '../utils/tree.js';
export { GfxController } from './controller.js';
export type { CursorType, StandardCursor } from './cursor.js';
export type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './element-transform/drag.js';
export { CanvasEventHandler } from './element-transform/extension/canvas-event-handler.js';
export {
ElementTransformManager,
TransformExtension,
TransformExtensionIdentifier,
TransformManagerIdentifier,
} from './element-transform/transform-manager.js';
export type {
DragEndContext,
DragMoveContext,
DragStartContext,
} from './element-transform/view-transform.js';
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
export { GridManager } from './grid.js';
export { GfxControllerIdentifier } from './identifiers.js';

View File

@@ -1,9 +1,12 @@
import { DisposableGroup } from '@blocksuite/global/disposable';
import { onSurfaceAdded } from '../../utils/gfx.js';
import {
type GfxBlockComponent,
isGfxBlockComponent,
} from '../../view/index.js';
import type { GfxController } from '../controller.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
import type { GfxModel } from '../model/model.js';
import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
@@ -34,20 +37,22 @@ export class ViewManager extends GfxExtension {
});
}
get(model: GfxModel | GfxLocalElementModel | string) {
if (typeof model === 'string') {
if (this._viewMap.has(model)) {
return this._viewMap.get(model);
}
get(
model: GfxModel | GfxLocalElementModel | string
): GfxElementModelView | GfxBlockComponent | null {
model = typeof model === 'string' ? model : model.id;
return this.std.view.getBlock(model) ?? null;
} else {
if (model instanceof GfxBlockElementModel) {
return this.std.view.getBlock(model.id) ?? null;
} else {
return this._viewMap.get(model.id) ?? null;
}
if (this._viewMap.has(model)) {
return this._viewMap.get(model)!;
}
const blockView = this.std.view.getBlock(model);
if (blockView && isGfxBlockComponent(blockView)) {
return blockView;
}
return null;
}
override mounted(): void {

View File

@@ -6,9 +6,15 @@ import type { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../event/index.js';
import type { EditorHost } from '../../view/index.js';
import type {
DragEndContext,
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
} from '../element-transform/view-transform.js';
import type { GfxController } from '../index.js';
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
import type { GfxLocalElementModel } from '../model/surface/local-element-model.js';
export type EventsHandlerMap = {
@@ -33,7 +39,7 @@ export class GfxElementModelView<
| GfxLocalElementModel,
RendererContext = object,
>
implements GfxElementGeometry, Extension
implements GfxElementGeometry, Extension, GfxViewTransformInterface
{
static type: string;
@@ -166,6 +172,26 @@ export class GfxElementModelView<
onCreated() {}
onDragStart(_: DragStartContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
this.model.stash('xywh');
}
}
onDragEnd(_: DragEndContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
this.model.pop('xywh');
}
}
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onResize = () => {};
onRotate = () => {};
/**
* Called when the view is destroyed.
* Override this method requires calling `super.onDestroyed()`.

View File

@@ -4,6 +4,10 @@ import { computed } from '@preact/signals-core';
import { nothing } from 'lit';
import type { BlockService } from '../../extension/index.js';
import type {
DragMoveContext,
GfxViewTransformInterface,
} from '../../gfx/element-transform/view-transform.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/index.js';
import { SurfaceSelection } from '../../selection/index.js';
@@ -47,10 +51,13 @@ function handleGfxConnection(instance: GfxBlockComponent) {
}
export abstract class GfxBlockComponent<
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
> extends BlockComponent<Model, Service, WidgetName> {
Model extends GfxBlockElementModel = GfxBlockElementModel,
Service extends BlockService = BlockService,
WidgetName extends string = string,
>
extends BlockComponent<Model, Service, WidgetName>
implements GfxViewTransformInterface
{
[GfxElementSymbol] = true;
get gfx() {
@@ -62,6 +69,22 @@ export abstract class GfxBlockComponent<
handleGfxConnection(this);
}
onDragMove = ({ dx, dy, currentBound }: DragMoveContext) => {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
};
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
onRotate() {}
onResize() {}
getCSSTransform() {
const viewport = this.gfx.viewport;
const { translateX, translateY, zoom } = viewport;
@@ -151,6 +174,22 @@ export function toGfxBlockComponent<
return selection.is(SurfaceSelection);
});
onDragMove({ dx, dy, currentBound }: DragMoveContext) {
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onDragStart() {
this.model.stash('xywh');
}
onDragEnd() {
this.model.pop('xywh');
}
onRotate() {}
onResize() {}
get gfx() {
return this.std.get(GfxControllerIdentifier);
}