refactor: redesign element transform manager interface (#11679)

### Change
- Rename `ElementTransformManager` -> `InteractivityManager`
- Now you can `event.on` and `action.onXXX` method to extend interactivity behaviour. The old approach of overriding methods directly is deprecated.
This commit is contained in:
doouding
2025-04-22 08:18:22 +00:00
parent e457e2f8a8
commit 52953ce8e3
22 changed files with 507 additions and 389 deletions
@@ -0,0 +1,8 @@
export type SupportedEvents =
| 'click'
| 'dblclick'
| 'pointerdown'
| 'pointerenter'
| 'pointerleave'
| 'pointermove'
| 'pointerup';
@@ -0,0 +1,141 @@
import { type Container, createIdentifier } from '@blocksuite/global/di';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import { Extension } from '@blocksuite/store';
import type { PointerEventState } from '../../../event/index.js';
import type { GfxController } from '../../controller.js';
import { GfxControllerIdentifier } from '../../identifiers.js';
import type { SupportedEvents } from '../event.js';
import type {
DragExtensionInitializeContext,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from '../types/drag.js';
export const InteractivityExtensionIdentifier =
createIdentifier<InteractivityExtension>('interactivity-extension');
export class InteractivityExtension extends Extension {
static key: string;
get std() {
return this.gfx.std;
}
event: Omit<InteractivityEventAPI, 'emit'> = new InteractivityEventAPI();
action: Omit<InteractivityActionAPI, 'emit'> = new InteractivityActionAPI();
constructor(protected readonly gfx: GfxController) {
super();
}
mounted() {}
/**
* Override this method should call `super.unmounted()`
*/
unmounted() {
this.event.destroy();
this.action.destroy();
}
static override setup(di: Container) {
if (!this.key) {
throw new BlockSuiteError(
ErrorCode.ValueNotExists,
'key is not defined in the InteractivityExtension'
);
}
di.add(
this as unknown as { new (gfx: GfxController): InteractivityExtension },
[GfxControllerIdentifier]
);
di.addImpl(InteractivityExtensionIdentifier(this.key), provider =>
provider.get(this)
);
}
}
export class InteractivityEventAPI {
private readonly _handlersMap = new Map<
SupportedEvents,
((evt: PointerEventState) => void)[]
>();
on(eventName: SupportedEvents, handler: (evt: PointerEventState) => void) {
const handlers = this._handlersMap.get(eventName) ?? [];
handlers.push(handler);
this._handlersMap.set(eventName, handlers);
return () => {
const idx = handlers.indexOf(handler);
if (idx > -1) {
handlers.splice(idx, 1);
}
};
}
emit(eventName: SupportedEvents, evt: PointerEventState) {
const handlers = this._handlersMap.get(eventName);
if (!handlers) {
return;
}
for (const handler of handlers) {
handler(evt);
}
}
destroy() {
this._handlersMap.clear();
}
}
type ActionContextMap = {
dragInitialize: {
context: DragExtensionInitializeContext;
returnType: {
onDragStart?: (context: ExtensionDragStartContext) => void;
onDragMove?: (context: ExtensionDragMoveContext) => void;
onDragEnd?: (context: ExtensionDragEndContext) => void;
clear?: () => void;
};
};
};
export class InteractivityActionAPI {
private readonly _handlers: Partial<{
dragInitialize: Parameters<InteractivityActionAPI['onDragInitialize']>[0];
}> = {};
onDragInitialize(
handler: (
ctx: ActionContextMap['dragInitialize']['context']
) => ActionContextMap['dragInitialize']['returnType']
) {
this._handlers['dragInitialize'] = handler;
return () => {
delete this._handlers['dragInitialize'];
};
}
emit<K extends keyof ActionContextMap>(
event: K,
context: ActionContextMap[K]['context']
): ActionContextMap[K]['returnType'] | undefined {
const handler = this._handlers[event];
return handler?.(context);
}
destroy() {
for (const key in this._handlers) {
delete this._handlers[key as keyof typeof this._handlers];
}
}
}
@@ -0,0 +1,63 @@
import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import type { PointerEventState } from '../../event';
import type { GfxController } from '../controller.js';
import type { GfxElementModelView } from '../view/view.js';
export class GfxViewEventManager {
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);
}
}
constructor(private readonly gfx: GfxController) {}
click(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('click', _evt);
}
dblClick(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
}
pointerDown(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
}
pointerMove(_evt: PointerEventState): void {
const [x, y] = this.gfx.viewport.toModelCoord(_evt.x, _evt.y);
const hoveredElmViews = this.gfx.grid
.search(new Bound(x - 5, y - 5, 10, 10), {
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;
}
pointerUp(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
}
}
@@ -0,0 +1,17 @@
export { InteractivityExtension } from './extension/base.js';
export { GfxViewEventManager } from './gfx-view-event-handler.js';
export { InteractivityIdentifier, InteractivityManager } from './manager.js';
export type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './types/drag.js';
export type {
DragEndContext,
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
SelectedContext,
} from './types/view.js';
@@ -0,0 +1,291 @@
import { type ServiceIdentifier } from '@blocksuite/global/di';
import { DisposableGroup } from '@blocksuite/global/disposable';
import { Bound, Point } from '@blocksuite/global/gfx';
import type { PointerEventState } from '../../event/state/pointer.js';
import { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
import type { GfxModel } from '../model/model.js';
import type { SupportedEvents } from './event.js';
import {
type InteractivityActionAPI,
type InteractivityEventAPI,
InteractivityExtensionIdentifier,
} from './extension/base.js';
import { GfxViewEventManager } from './gfx-view-event-handler.js';
import type {
DragExtensionInitializeContext,
DragInitializationOption,
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './types/drag.js';
type ExtensionPointerHandler = Exclude<
SupportedEvents,
'pointerleave' | 'pointerenter'
>;
export const InteractivityIdentifier = GfxExtensionIdentifier(
'interactivity-manager'
) as ServiceIdentifier<InteractivityManager>;
const CAMEL_CASE_MAP: {
[key in ExtensionPointerHandler]: keyof GfxViewEventManager;
} = {
click: 'click',
dblclick: 'dblClick',
pointerdown: 'pointerDown',
pointermove: 'pointerMove',
pointerup: 'pointerUp',
};
export class InteractivityManager extends GfxExtension {
static override key = 'interactivity-manager';
private readonly _disposable = new DisposableGroup();
private canvasEventHandler = new GfxViewEventManager(this.gfx);
override mounted(): void {
this.canvasEventHandler = new GfxViewEventManager(this.gfx);
this.interactExtensions.forEach(ext => {
ext.mounted();
});
}
override unmounted(): void {
this._disposable.dispose();
this.interactExtensions.forEach(ext => {
ext.unmounted();
});
}
get interactExtensions() {
return this.std.provider.getAll(InteractivityExtensionIdentifier);
}
get keyboard() {
return this.gfx.keyboard;
}
private _safeExecute(fn: () => void, errorMessage: string) {
try {
fn();
} catch (e) {
console.error(errorMessage, e);
}
}
/**
* Dispatch the event to canvas elements
* @param eventName
* @param evt
*/
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const handlerName = CAMEL_CASE_MAP[eventName];
this.canvasEventHandler[handlerName](evt);
const extensions = this.interactExtensions;
extensions.forEach(ext => {
(ext.event as InteractivityEventAPI).emit(eventName, evt);
});
}
dispatchOnSelected(evt: PointerEventState) {
const { raw } = evt;
const { gfx } = this;
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
const picked = this.gfx.getElementInGroup(x, y);
const tryGetLockedAncestor = (e: GfxModel) => {
if (e?.isLockedByAncestor()) {
return e.groups.findLast(group => group.isLocked()) ?? e;
}
return e;
};
if (picked) {
const lockedElement = tryGetLockedAncestor(picked);
const multiSelect = raw.shiftKey;
const view = gfx.view.get(lockedElement);
const context = {
selected: multiSelect ? !gfx.selection.has(picked.id) : true,
multiSelect,
event: raw,
position: Point.from([x, y]),
fallback: lockedElement !== picked,
};
const selected = view?.onSelected(context);
return selected ?? true;
}
return false;
}
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.interactExtensions;
const activeExtensionHandlers = Array.from(
extension.values().map(ext => {
return (ext.action as InteractivityActionAPI).emit(
'dragInitialize',
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();
}
}
@@ -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;
@@ -0,0 +1,77 @@
import type { Bound, 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';
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 SelectedContext = {
/**
* The selected state of the element
*/
selected: boolean;
/**
* Whether is multi-select, usually triggered by shift key
*/
multiSelect: boolean;
/**
* The pointer event that triggers the selection
*/
event: PointerEvent;
/**
* The model position of the event pointer
*/
position: IPoint;
/**
* If the current selection is a fallback selection, like selecting the element inside a group, the group will be selected instead
*/
fallback: boolean;
};
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
* @param context
* @returns
*/
onSelected: (context: SelectedContext) => void;
};