refactor: rewrite dblclick and selection logic of default-tool (#11036)

continue #10824

### Changed
- Moved double-click-to-edit behavior from the default tool to individual model views
- Introduced `onSelected` callback interface in gfx view components to allows developers to override default selection logic
This commit is contained in:
doouding
2025-03-26 07:32:43 +00:00
parent 61c0d01da3
commit ace5d44a61
38 changed files with 779 additions and 353 deletions

View File

@@ -2,12 +2,10 @@ import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import type { PointerEventState } from '../../../event';
import type { GfxController } from '../..';
import type { GfxElementModelView } from '../../view/view';
import { TransformExtension } from '../transform-manager';
export class CanvasEventHandler extends TransformExtension {
static override key = 'canvas-event-handler';
export class CanvasEventHandler {
private _currentStackedElm: GfxElementModelView[] = [];
private _callInReverseOrder(
@@ -21,22 +19,24 @@ export class CanvasEventHandler extends TransformExtension {
}
}
override click(_evt: PointerEventState): void {
constructor(private readonly gfx: GfxController) {}
click(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('click', _evt);
}
override dblClick(_evt: PointerEventState): void {
dblClick(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('dblclick', _evt);
}
override pointerDown(_evt: PointerEventState): void {
pointerDown(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerdown', _evt);
}
override pointerMove(_evt: PointerEventState): void {
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), {
.search(new Bound(x - 5, y - 5, 10, 10), {
filter: ['canvas', 'local'],
})
.map(model => this.gfx.view.get(model)) as GfxElementModelView[];
@@ -57,7 +57,7 @@ export class CanvasEventHandler extends TransformExtension {
this._currentStackedElm = hoveredElmViews;
}
override pointerUp(_evt: PointerEventState): void {
pointerUp(_evt: PointerEventState): void {
last(this._currentStackedElm)?.dispatch('pointerup', _evt);
}
}

View File

@@ -12,6 +12,7 @@ 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 { GfxModel } from '../model/model.js';
import { type SupportedEvent } from '../view/view.js';
import type {
DragExtensionInitializeContext,
@@ -20,6 +21,7 @@ import type {
ExtensionDragMoveContext,
ExtensionDragStartContext,
} from './drag.js';
import { CanvasEventHandler } from './extension/canvas-event-handler.js';
type ExtensionPointerHandler = Exclude<
SupportedEvent,
@@ -31,10 +33,7 @@ export const TransformManagerIdentifier = GfxExtensionIdentifier(
) as ServiceIdentifier<ElementTransformManager>;
const CAMEL_CASE_MAP: {
[key in ExtensionPointerHandler]: keyof Pick<
TransformExtension,
'click' | 'dblClick' | 'pointerDown' | 'pointerMove' | 'pointerUp'
>;
[key in ExtensionPointerHandler]: keyof CanvasEventHandler;
} = {
click: 'click',
dblclick: 'dblClick',
@@ -48,8 +47,10 @@ export class ElementTransformManager extends GfxExtension {
private readonly _disposable = new DisposableGroup();
private canvasEventHandler = new CanvasEventHandler(this.gfx);
override mounted(): void {
//
this.canvasEventHandler = new CanvasEventHandler(this.gfx);
}
override unmounted(): void {
@@ -72,20 +73,55 @@ export class ElementTransformManager extends GfxExtension {
}
}
/**
* Dispatch the event to canvas elements
* @param eventName
* @param evt
*/
dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) {
const transformExtensions = this.transformExtensions;
const handlerName = CAMEL_CASE_MAP[eventName];
transformExtensions.forEach(ext => {
const handlerMethodName = CAMEL_CASE_MAP[eventName];
this.canvasEventHandler[handlerName](evt);
if (ext[handlerMethodName]) {
this._safeExecute(() => {
ext[handlerMethodName](evt);
}, `Error while executing extension \`${handlerMethodName}\` handler`);
}
const extension = this.transformExtensions;
extension.forEach(ext => {
ext[handlerName]?.(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,
};
view?.onSelected(context);
return true;
}
return false;
}
initializeDrag(options: DragInitializationOption) {
let cancelledByExt = false;

View File

@@ -1,4 +1,4 @@
import type { Bound } from '@blocksuite/global/gfx';
import type { Bound, IPoint } from '@blocksuite/global/gfx';
import type { GfxBlockComponent } from '../../view';
import type { GfxModel } from '../model/model';
@@ -34,10 +34,44 @@ export type DragMoveContext = DragStartContext & {
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;
};

View File

@@ -31,6 +31,7 @@ export type {
DragMoveContext,
DragStartContext,
} from './element-transform/view-transform.js';
export { type SelectedContext } from './element-transform/view-transform.js';
export { GfxExtension, GfxExtensionIdentifier } from './extension.js';
export { GridManager } from './grid.js';
export { GfxControllerIdentifier } from './identifiers.js';

View File

@@ -316,7 +316,7 @@ export class GfxSelectionManager extends GfxExtension {
}
const { blocks = [], elements = [] } = groupBy(selection.elements, id => {
return this.std.store.getModelById(id) ? 'blocks' : 'elements';
return this.std.store.hasBlock(id) ? 'blocks' : 'elements';
});
let instances: (SurfaceSelection | CursorSelection)[] = [];
@@ -372,6 +372,21 @@ export class GfxSelectionManager extends GfxExtension {
}
}
/**
* Toggle the selection state of single element
* @param element
* @returns
*/
toggle(element: GfxModel | string) {
element = typeof element === 'string' ? element : element.id;
this.set({
elements: this.has(element)
? this.selectedIds.filter(id => id !== element)
: [...this.selectedIds, element],
});
}
setCursor(cursor: CursorSelection | IPoint) {
const instance = this.stdSelection.create(
CursorSelection,

View File

@@ -506,6 +506,7 @@ export class ToolController extends GfxExtension {
return;
}
// explicitly clear the selection when switching tools
this.gfx.selection.set({ elements: [] });
this.currentTool$.peek()?.deactivate();

View File

@@ -11,6 +11,7 @@ import type {
DragMoveContext,
DragStartContext,
GfxViewTransformInterface,
SelectedContext,
} from '../element-transform/view-transform.js';
import type { GfxController } from '../index.js';
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
@@ -70,6 +71,10 @@ export class GfxElementModelView<
return this.model.type;
}
get std() {
return this.gfx.std;
}
constructor(
model: T,
readonly gfx: GfxController
@@ -188,6 +193,16 @@ export class GfxElementModelView<
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
}
onSelected(context: SelectedContext) {
if (this.model instanceof GfxPrimitiveElementModel) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
}
onResize = () => {};
onRotate = () => {};

View File

@@ -7,9 +7,10 @@ import type { BlockService } from '../../extension/index.js';
import type {
DragMoveContext,
GfxViewTransformInterface,
SelectedContext,
} from '../../gfx/element-transform/view-transform.js';
import { GfxControllerIdentifier } from '../../gfx/identifiers.js';
import type { GfxBlockElementModel } from '../../gfx/index.js';
import { type GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
import { SurfaceSelection } from '../../selection/index.js';
import { BlockComponent } from './block-component.js';
@@ -81,6 +82,14 @@ export abstract class GfxBlockComponent<
this.model.pop('xywh');
}
onSelected(context: SelectedContext) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
onRotate() {}
onResize() {}
@@ -186,6 +195,15 @@ export function toGfxBlockComponent<
this.model.pop('xywh');
}
// eslint-disable-next-line sonarjs/no-identical-functions
onSelected(context: SelectedContext) {
if (context.multiSelect) {
this.gfx.selection.toggle(this.model);
} else {
this.gfx.selection.set({ elements: [this.model.id] });
}
}
onRotate() {}
onResize() {}