refactor: moving connector label to connector view (#11738)

### Changed
Moved connector label moving logic from `default-tool` to connector view.

#### Other infrastructure changes:​​
- Gfx element view now can handles drag events
- Added `context.preventDefault()` support to bypass built-in interactions in extension
- Handle the pointer events in element view will bypass the built-in interactions automatically

> The built-in interactions include element dragging, click selection, drag-to-scale operations, etc.
This commit is contained in:
doouding
2025-04-22 08:18:24 +00:00
parent 21bf009553
commit e0e84d302d
12 changed files with 305 additions and 130 deletions

View File

@@ -1,14 +1,10 @@
export enum DefaultModeDragType {
/** Moving connector label */
ConnectorLabelMoving = 'connector-label-moving',
/** Moving selected contents */
ContentMoving = 'content-moving',
/** Native range dragging inside active note block */
NativeEditing = 'native-editing',
/** Default void state */
None = 'none',
/** Dragging preview */
PreviewDragging = 'preview-dragging',
/** Expanding the dragging area, select the content covered inside */
Selecting = 'selecting',
}

View File

@@ -2,12 +2,8 @@ import {
type FrameOverlay,
isFrameBlock,
} from '@blocksuite/affine-block-frame';
import { OverlayIdentifier } from '@blocksuite/affine-block-surface';
import {
ConnectorUtils,
OverlayIdentifier,
} from '@blocksuite/affine-block-surface';
import {
type ConnectorElementModel,
GroupElementModel,
MindmapElementModel,
NoteBlockModel,
@@ -16,7 +12,7 @@ import {
import { resetNativeSelection } from '@blocksuite/affine-shared/utils';
import { DisposableGroup } from '@blocksuite/global/disposable';
import type { IVec } from '@blocksuite/global/gfx';
import { Bound, Vec } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import {
BaseTool,
@@ -29,7 +25,6 @@ import {
import { effect } from '@preact/signals-core';
import { calPanDelta } from '../utils/panning-utils.js';
import { isCanvasElement } from '../utils/query.js';
import { DefaultModeDragType } from './default-tool-ext/ext.js';
export class DefaultTool extends BaseTool {
@@ -59,11 +54,6 @@ export class DefaultTool extends BaseTool {
this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]);
};
// For moving the connector label
private _selectedConnector: ConnectorElementModel | null = null;
private _selectedConnectorLabelBounds: Bound | null = null;
private _selectionRectTransition: null | {
w: number;
h: number;
@@ -168,6 +158,8 @@ export class DefaultTool extends BaseTool {
enableHover = true;
dragging = false;
/**
* Get the end position of the dragging area in the model coordinate
*/
@@ -232,20 +224,6 @@ export class DefaultTool extends BaseTool {
editing: false,
});
}
if (
isCanvasElement(selected) &&
ConnectorUtils.isConnectorWithLabel(selected) &&
(selected as ConnectorElementModel).labelIncludesPoint(
this.gfx.viewport.toModelCoord(x, y)
)
) {
this._selectedConnector = selected as ConnectorElementModel;
this._selectedConnectorLabelBounds = Bound.fromXYWH(
this._selectedConnector.labelXYWH!
);
return DefaultModeDragType.ConnectorLabelMoving;
}
}
return this.edgelessSelectionManager.editing
@@ -259,20 +237,6 @@ export class DefaultTool extends BaseTool {
editing: false,
});
if (
isCanvasElement(selected) &&
ConnectorUtils.isConnectorWithLabel(selected) &&
(selected as ConnectorElementModel).labelIncludesPoint(
this.gfx.viewport.toModelCoord(x, y)
)
) {
this._selectedConnector = selected as ConnectorElementModel;
this._selectedConnectorLabelBounds = Bound.fromXYWH(
this._selectedConnector.labelXYWH!
);
return DefaultModeDragType.ConnectorLabelMoving;
}
return DefaultModeDragType.ContentMoving;
} else {
return DefaultModeDragType.Selecting;
@@ -280,24 +244,6 @@ export class DefaultTool extends BaseTool {
}
}
private _moveLabel(delta: IVec) {
const connector = this._selectedConnector;
let bounds = this._selectedConnectorLabelBounds;
if (!connector || !bounds) return;
bounds = bounds.clone();
const center = connector.getNearestPoint(
Vec.add(bounds.center, delta) as IVec
);
const distance = connector.getOffsetDistanceByPoint(center as IVec);
bounds.center = center;
this.gfx.updateElement(connector, {
labelXYWH: bounds.toXYWH(),
labelOffset: {
distance,
},
});
}
private _pick(x: number, y: number, options?: PointTestOptions) {
const modelPos = this.gfx.viewport.toModelCoord(x, y);
@@ -387,7 +333,7 @@ export class DefaultTool extends BaseTool {
resetNativeSelection(null);
}
this.interactivity?.dispatch('click', e);
this.interactivity?.dispatchEvent('click', e);
}
override deactivate() {
@@ -409,21 +355,28 @@ export class DefaultTool extends BaseTool {
return;
}
this.interactivity?.dispatch('dblclick', e);
this.interactivity?.dispatchEvent('dblclick', e);
}
override dragEnd() {
if (this.edgelessSelectionManager.editing) return;
override dragEnd(e: PointerEventState) {
this.interactivity?.dispatchEvent('dragend', e);
if (this.edgelessSelectionManager.editing || !this.dragging) return;
this.dragging = false;
this.frameOverlay.clear();
this._toBeMoved = [];
this._selectedConnector = null;
this._selectedConnectorLabelBounds = null;
this._clearSelectingState();
this.dragType = DefaultModeDragType.None;
}
override dragMove(e: PointerEventState) {
this.interactivity?.dispatchEvent('dragmove', e);
if (!this.dragging) {
return;
}
const { viewport } = this.gfx;
switch (this.dragType) {
case DefaultModeDragType.Selecting: {
@@ -441,12 +394,6 @@ export class DefaultTool extends BaseTool {
case DefaultModeDragType.ContentMoving: {
break;
}
case DefaultModeDragType.ConnectorLabelMoving: {
const dx = this.dragLastPos[0] - this.dragStartPos[0];
const dy = this.dragLastPos[1] - this.dragStartPos[1];
this._moveLabel([dx, dy]);
break;
}
case DefaultModeDragType.NativeEditing: {
// TODO reset if drag out of note
break;
@@ -456,7 +403,18 @@ export class DefaultTool extends BaseTool {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
override async dragStart(e: PointerEventState) {
if (this.edgelessSelectionManager.editing) return;
const { preventDefaultState, handledByView } =
this.interactivity?.dispatchEvent('dragstart', e) ?? {};
if (
this.edgelessSelectionManager.editing ||
preventDefaultState ||
handledByView
)
return;
this.dragging = true;
// Determine the drag type based on the current state and event
let dragType = this._determineDragType(e);
@@ -508,7 +466,7 @@ export class DefaultTool extends BaseTool {
}
override pointerDown(e: PointerEventState): void {
this.interactivity?.dispatch('pointerdown', e);
this.interactivity?.dispatchEvent('pointerdown', e);
}
override pointerMove(e: PointerEventState) {
@@ -527,11 +485,11 @@ export class DefaultTool extends BaseTool {
this.frameOverlay.clear();
}
this.interactivity?.dispatch('pointermove', e);
this.interactivity?.dispatchEvent('pointermove', e);
}
override pointerUp(e: PointerEventState) {
this.interactivity?.dispatch('pointerup', e);
this.interactivity?.dispatchEvent('pointerup', e);
}
override tripleClick() {}

View File

@@ -6,14 +6,17 @@ import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import type { PointerEventState } from '@blocksuite/std';
import { InteractivityExtension } from '@blocksuite/std/gfx';
import {
type GfxInteractivityContext,
InteractivityExtension,
} from '@blocksuite/std/gfx';
export class DblClickAddEdgelessText extends InteractivityExtension {
static override key = 'dbl-click-add-edgeless-text';
override mounted() {
this.event.on('dblclick', (e: PointerEventState) => {
this.event.on('dblclick', (ctx: GfxInteractivityContext) => {
const { event: e } = ctx;
const textFlag = this.std.store
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');

View File

@@ -1,8 +1,14 @@
import type { ConnectorElementModel } from '@blocksuite/affine-model';
import {
type ConnectorElementModel,
LocalShapeElementModel,
} from '@blocksuite/affine-model';
import { Bound, serializeXYWH, Vec } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import {
type DragEndContext,
type DragMoveContext,
type DragStartContext,
generateKeyBetween,
GfxElementModelView,
} from '@blocksuite/std/gfx';
@@ -30,11 +36,17 @@ export class ConnectorElementView extends GfxElementModelView<ConnectorElementMo
override onCreated(): void {
super.onCreated();
this._initDblClickToEdit();
this._initLabelMoving();
}
private _initDblClickToEdit(): void {
this.on('dblclick', evt => {
private _initLabelMoving(): void {
let curLabelElement: LocalShapeElementModel | null = null;
if (this.model.isLocked()) {
return;
}
const enterLabelEditor = (evt: PointerEventState) => {
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
if (edgeless && !this.model.isLocked()) {
@@ -44,6 +56,121 @@ export class ConnectorElementView extends GfxElementModelView<ConnectorElementMo
this.gfx.viewport.toModelCoord(evt.x, evt.y)
);
}
};
const getCurrentPosition = (evt: PointerEventState) => {
const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
return {
x,
y,
clientX: evt.raw.clientX,
clientY: evt.raw.clientY,
};
};
const watchEvent = (labelModel: LocalShapeElementModel) => {
const view = this.gfx.view.get(labelModel) as GfxElementModelView;
const connectorModel = this.model;
let labelBound: Bound | null = null;
let startPoint = {
x: 0,
y: 0,
clientX: 0,
clientY: 0,
};
let lastPoint = {
x: 0,
y: 0,
clientX: 0,
clientY: 0,
};
view.on('dblclick', evt => {
enterLabelEditor(evt);
});
view.on('dragstart', evt => {
startPoint = getCurrentPosition(evt);
labelBound = Bound.deserialize(labelModel.xywh);
connectorModel.stash('labelXYWH');
connectorModel.stash('labelOffset');
});
view.on('dragmove', evt => {
if (!labelBound) {
return;
}
lastPoint = getCurrentPosition(evt);
const newBound = labelBound.clone();
const delta = [lastPoint.x - startPoint.x, lastPoint.y - startPoint.y];
const center = connectorModel.getNearestPoint(
Vec.add(newBound.center, delta)
);
const distance = connectorModel.getOffsetDistanceByPoint(center);
newBound.center = center;
connectorModel.labelXYWH = newBound.toXYWH();
connectorModel.labelOffset = {
distance,
};
});
view.on('dragend', () => {
if (labelBound) {
labelBound = null;
connectorModel.pop('labelXYWH');
connectorModel.pop('labelOffset');
}
});
};
const updateLabelElement = () => {
if (!this.model.labelXYWH || !this.model.text) {
// Clean up existing label element if conditions are no longer met
if (curLabelElement) {
this.surface.deleteLocalElement(curLabelElement);
curLabelElement = null;
}
return;
}
const labelElement =
curLabelElement || new LocalShapeElementModel(this.surface);
labelElement.xywh = serializeXYWH(...this.model.labelXYWH);
labelElement.index = generateKeyBetween(this.model.index, null);
if (!curLabelElement) {
curLabelElement = labelElement;
labelElement.fillColor = 'transparent';
labelElement.strokeColor = 'transparent';
labelElement.strokeWidth = 0;
this.surface.addLocalElement(labelElement);
this.disposable.add(() => {
this.surface.deleteLocalElement(labelElement);
});
watchEvent(labelElement);
}
};
this.disposable.add(
this.model.propsUpdated.subscribe(payload => {
if (
payload.key === 'labelXYWH' ||
payload.key === 'text' ||
payload.key === 'index'
) {
updateLabelElement();
}
})
);
updateLabelElement();
this.on('dblclick', evt => {
if (!curLabelElement) {
enterLabelEditor(evt);
}
});
}
}

View File

@@ -1,4 +1,4 @@
import type { ShapeElementModel } from '@blocksuite/affine-model';
import { ShapeElementModel } from '@blocksuite/affine-model';
import { GfxElementModelView } from '@blocksuite/std/gfx';
import { mountShapeTextEditor } from './text/edgeless-shape-text-editor';
@@ -16,7 +16,11 @@ export class ShapeElementView extends GfxElementModelView<ShapeElementModel> {
this.on('dblclick', () => {
const edgeless = this.std.view.getBlock(this.std.store.root!.id);
if (edgeless && !this.model.isLocked()) {
if (
edgeless &&
!this.model.isLocked() &&
this.model instanceof ShapeElementModel
) {
mountShapeTextEditor(this.model, edgeless);
}
});

View File

@@ -24,6 +24,7 @@ export type {
ExtensionDragEndContext,
ExtensionDragMoveContext,
ExtensionDragStartContext,
GfxInteractivityContext,
SelectedContext,
} from './interactivity/index.js';
export {

View File

@@ -1,3 +1,5 @@
import type { PointerEventState, UIEventState } from '../../event';
export type SupportedEvents =
| 'click'
| 'dblclick'
@@ -5,4 +7,42 @@ export type SupportedEvents =
| 'pointerenter'
| 'pointerleave'
| 'pointermove'
| 'pointerup';
| 'pointerup'
| 'dragstart'
| 'dragmove'
| 'dragend';
export type GfxInteractivityContext<
EventState extends UIEventState = PointerEventState,
RawEvent extends Event = EventState['event'],
> = {
event: EventState;
/**
* The raw dom event.
*/
raw: RawEvent;
/**
* Prevent the default gfx interaction
*/
preventDefault: () => void;
};
export const createInteractionContext = (event: PointerEventState) => {
let preventDefaultState = false;
return {
context: {
event,
raw: event.raw,
preventDefault: () => {
preventDefaultState = true;
event.raw.preventDefault();
},
},
get preventDefaultState() {
return preventDefaultState;
},
};
};

View File

@@ -2,11 +2,10 @@ 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 { GfxModel } from '../../model/model.js';
import type { SupportedEvents } from '../event.js';
import type { GfxInteractivityContext, SupportedEvents } from '../event.js';
import type { ExtensionElementsCloneContext } from '../types/clone.js';
import type {
DragExtensionInitializeContext,
@@ -64,10 +63,13 @@ export class InteractivityExtension extends Extension {
export class InteractivityEventAPI {
private readonly _handlersMap = new Map<
SupportedEvents,
((evt: PointerEventState) => void)[]
((evt: GfxInteractivityContext) => void)[]
>();
on(eventName: SupportedEvents, handler: (evt: PointerEventState) => void) {
on(
eventName: SupportedEvents,
handler: (evt: GfxInteractivityContext) => void
) {
const handlers = this._handlersMap.get(eventName) ?? [];
handlers.push(handler);
this._handlersMap.set(eventName, handlers);
@@ -81,7 +83,7 @@ export class InteractivityEventAPI {
};
}
emit(eventName: SupportedEvents, evt: PointerEventState) {
emit(eventName: SupportedEvents, evt: GfxInteractivityContext) {
const handlers = this._handlersMap.get(eventName);
if (!handlers) {
return;

View File

@@ -3,14 +3,15 @@ import last from 'lodash-es/last';
import type { PointerEventState } from '../../event';
import type { GfxController } from '../controller.js';
import type { GfxElementModelView } from '../view/view.js';
import type { GfxElementModelView, SupportedEvent } from '../view/view.js';
export class GfxViewEventManager {
private _currentStackedElm: GfxElementModelView[] = [];
private _hoveredElementsStack: GfxElementModelView[] = [];
private _draggingElement: GfxElementModelView | null = null;
private _callInReverseOrder(
callback: (view: GfxElementModelView) => void,
arr = this._currentStackedElm
arr = this._hoveredElementsStack
) {
for (let i = arr.length - 1; i >= 0; i--) {
const view = arr[i];
@@ -21,26 +22,52 @@ export class GfxViewEventManager {
constructor(private readonly gfx: GfxController) {}
click(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('click', _evt);
dispatch(eventName: SupportedEvent, evt: PointerEventState) {
if (eventName === 'pointermove') {
this._handlePointerMove(evt);
return false;
} else if (eventName.startsWith('drag')) {
return this._handleDrag(
eventName as 'dragstart' | 'dragend' | 'dragmove',
evt
);
} else {
return last(this._hoveredElementsStack)?.dispatch(eventName, evt);
}
}
dblClick(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
private _handleDrag(
evtName: 'dragstart' | 'dragend' | 'dragmove',
_evt: PointerEventState
): boolean {
switch (evtName) {
case 'dragstart': {
if (this._draggingElement) {
this._draggingElement.dispatch('dragend', _evt);
}
this._draggingElement = last(this._hoveredElementsStack) ?? null;
return this._draggingElement?.dispatch('dragstart', _evt) ?? false;
}
case 'dragmove': {
return this._draggingElement?.dispatch('dragmove', _evt) ?? false;
}
case 'dragend': {
const dispatched =
this._draggingElement?.dispatch('dragend', _evt) ?? false;
this._draggingElement = null;
return dispatched;
}
}
}
pointerDown(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
}
pointerMove(_evt: PointerEventState): void {
private _handlePointerMove(_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 currentStackedViews = new Set(this._hoveredElementsStack);
const visited = new Set<GfxElementModelView>();
this._callInReverseOrder(view => {
@@ -54,10 +81,6 @@ export class GfxViewEventManager {
this._callInReverseOrder(
view => !visited.has(view) && view.dispatch('pointerleave', _evt)
);
this._currentStackedElm = hoveredElmViews;
}
pointerUp(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
this._hoveredElementsStack = hoveredElmViews;
}
}

View File

@@ -1,3 +1,4 @@
export type { GfxInteractivityContext } from './event.js';
export { InteractivityExtension } from './extension/base.js';
export { GfxViewEventManager } from './gfx-view-event-handler.js';
export { InteractivityIdentifier, InteractivityManager } from './manager.js';

View File

@@ -5,7 +5,7 @@ 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 { createInteractionContext, type SupportedEvents } from './event.js';
import {
type InteractivityActionAPI,
type InteractivityEventAPI,
@@ -30,16 +30,6 @@ 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';
@@ -78,20 +68,26 @@ export class InteractivityManager extends GfxExtension {
}
/**
* Dispatch the event to canvas elements
* Dispatch event to extensions and gfx view.
* @param eventName
* @param evt
* @returns
*/
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const handlerName = CAMEL_CASE_MAP[eventName];
this.canvasEventHandler[handlerName](evt);
dispatchEvent(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const { context, preventDefaultState } = createInteractionContext(evt);
const extensions = this.interactExtensions;
extensions.forEach(ext => {
(ext.event as InteractivityEventAPI).emit(eventName, evt);
(ext.event as InteractivityEventAPI).emit(eventName, context);
});
const handledByView =
this.canvasEventHandler.dispatch(eventName, evt) ?? false;
return {
preventDefaultState,
handledByView,
};
}
dispatchOnSelected(evt: PointerEventState) {
@@ -126,6 +122,14 @@ export class InteractivityManager extends GfxExtension {
return false;
}
/**
* Initialize drag operation for elements.
* Handles drag start, move and end events automatically.
* Note: Call this when mouse is already down.
*
* @param options
* @returns
*/
initializeDrag(options: DragInitializationOption) {
let cancelledByExt = false;

View File

@@ -26,6 +26,9 @@ export type EventsHandlerMap = {
pointerleave: PointerEventState;
pointermove: PointerEventState;
pointerup: PointerEventState;
dragstart: PointerEventState;
dragmove: PointerEventState;
dragend: PointerEventState;
};
export type SupportedEvent = keyof EventsHandlerMap;
@@ -97,11 +100,24 @@ export class GfxElementModelView<
return this.model.containsBound(bounds);
}
/**
* Dispatches an event to the view.
* @param event
* @param evt
* @returns Whether the event view has any handlers for the event.
*/
dispatch<K extends keyof EventsHandlerMap>(
event: K,
evt: EventsHandlerMap[K]
) {
this._handlers.get(event)?.forEach(callback => callback(evt));
const handlers = this._handlers.get(event);
if (handlers?.length) {
handlers.forEach(callback => callback(evt));
return true;
}
return false;
}
getLineIntersections(start: IVec, end: IVec) {