mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-15 05:37:32 +00:00
fix: rewrite selection logic and frame selection handling logic (#12421)
Fixes [BS-3528](https://linear.app/affine-design/issue/BS-3528) Fixes [BS-3331](https://linear.app/affine-design/issue/BS-3331/frame-移动逻辑很奇怪) ### Changed - Remove `onSelected` method from gfx view, use `handleSelection` provided by `GfxViewInteraction` instead. - Add `selectable` to allow model to filter out itself from selection. - Frame can be selected by body only if it's locked or its background is not transparent. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced selection behavior for frames, edgeless text, notes, and mind map elements with refined control based on lock state and background transparency. - Introduced group-aware selection logic promoting selection of appropriate group ancestors. - Added support for element selection events in interactivity extensions. - **Bug Fixes** - Resolved frame selection issues by enabling selection via title clicks and restricting body selection to locked frames or those with non-transparent backgrounds. - **Documentation** - Added clarifying comments for group retrieval methods. - **Tests** - Updated and added end-to-end tests for frame and lock selection reflecting new selection conditions. - **Refactor** - Unified and simplified selection handling by moving logic from component methods to interaction handlers and removing deprecated selection methods. - Streamlined selection candidate processing with extension-driven target suggestion. - Removed legacy group element retrieval and selection helper methods to simplify interaction logic. - **Style** - Renamed types and improved type signatures for selection context and interaction configurations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -22,10 +22,7 @@ import {
|
||||
GfxBlockComponent,
|
||||
TextSelection,
|
||||
} from '@blocksuite/std';
|
||||
import {
|
||||
GfxViewInteractionExtension,
|
||||
type SelectedContext,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
|
||||
import { computed } from '@preact/signals-core';
|
||||
import { css, html } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
@@ -282,69 +279,6 @@ export class EdgelessTextBlockComponent extends GfxBlockComponent<EdgelessTextBl
|
||||
};
|
||||
}
|
||||
|
||||
override onSelected(context: SelectedContext): void | boolean {
|
||||
const { selected, multiSelect, event: e } = context;
|
||||
const { editing } = this.gfx.selection;
|
||||
const alreadySelected = this.gfx.selection.has(this.model.id);
|
||||
|
||||
if (!multiSelect && selected && (alreadySelected || editing)) {
|
||||
if (this.model.isLocked()) return;
|
||||
|
||||
if (alreadySelected && editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [this.model.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model.children.length === 0) {
|
||||
const blockId = this.store.addBlock(
|
||||
'affine:paragraph',
|
||||
{ type: 'text' },
|
||||
this.model.id
|
||||
);
|
||||
|
||||
if (blockId) {
|
||||
focusTextModel(this.std, blockId);
|
||||
}
|
||||
} else {
|
||||
const rect = this.querySelector(
|
||||
'.affine-block-children-container'
|
||||
)?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
const offsetY = 8 * this.gfx.viewport.zoom;
|
||||
const offsetX = 2 * this.gfx.viewport.zoom;
|
||||
const x = clamp(
|
||||
e.clientX,
|
||||
rect.left + offsetX,
|
||||
rect.right - offsetX
|
||||
);
|
||||
const y = clamp(
|
||||
e.clientY,
|
||||
rect.top + offsetY,
|
||||
rect.bottom - offsetY
|
||||
);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
} else {
|
||||
handleNativeRangeAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
return super.onSelected(context);
|
||||
}
|
||||
}
|
||||
|
||||
override renderGfxBlock() {
|
||||
const { model } = this;
|
||||
const { rotate, hasMaxWidth } = model.props;
|
||||
@@ -506,5 +440,73 @@ export const EdgelessTextInteraction =
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
handleSelection: context => {
|
||||
const { gfx, std, view, model } = context;
|
||||
return {
|
||||
onSelect(context) {
|
||||
const { selected, multiSelect, event: e } = context;
|
||||
const { editing } = gfx.selection;
|
||||
const alreadySelected = gfx.selection.has(model.id);
|
||||
|
||||
if (!multiSelect && selected && (alreadySelected || editing)) {
|
||||
if (model.isLocked()) return;
|
||||
|
||||
if (alreadySelected && editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
gfx.selection.set({
|
||||
elements: [model.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
view.updateComplete
|
||||
.then(() => {
|
||||
if (!view.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.children.length === 0) {
|
||||
const blockId = std.store.addBlock(
|
||||
'affine:paragraph',
|
||||
{ type: 'text' },
|
||||
model.id
|
||||
);
|
||||
|
||||
if (blockId) {
|
||||
focusTextModel(std, blockId);
|
||||
}
|
||||
} else {
|
||||
const rect = view
|
||||
.querySelector('.affine-block-children-container')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
const offsetY = 8 * gfx.viewport.zoom;
|
||||
const offsetX = 2 * gfx.viewport.zoom;
|
||||
const x = clamp(
|
||||
e.clientX,
|
||||
rect.left + offsetX,
|
||||
rect.right - offsetX
|
||||
);
|
||||
const y = clamp(
|
||||
e.clientY,
|
||||
rect.top + offsetY,
|
||||
rect.bottom - offsetY
|
||||
);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
} else {
|
||||
handleNativeRangeAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
return context.default(context);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DefaultTheme,
|
||||
type FrameBlockModel,
|
||||
FrameBlockSchema,
|
||||
isTransparent,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
type BoxSelectionContext,
|
||||
getTopElements,
|
||||
GfxViewInteractionExtension,
|
||||
type SelectedContext,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { html } from 'lit';
|
||||
@@ -68,22 +68,6 @@ export class FrameBlockComponent extends GfxBlockComponent<FrameBlockModel> {
|
||||
};
|
||||
}
|
||||
|
||||
override onSelected(context: SelectedContext): boolean | void {
|
||||
const { x, y } = context.position;
|
||||
|
||||
if (
|
||||
!context.fallback &&
|
||||
// if the frame is selected by title, then ignore it because the title selection is handled by the title widget
|
||||
(this.model.externalBound?.containsPoint([x, y]) ||
|
||||
// otherwise if the frame has title, then ignore it because in this case the frame cannot be selected by frame body
|
||||
this.model.props.title.length)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return super.onSelected(context);
|
||||
}
|
||||
|
||||
override onBoxSelected(context: BoxSelectionContext) {
|
||||
const { box } = context;
|
||||
const bound = new Bound(box.x, box.y, box.w, box.h);
|
||||
@@ -189,5 +173,17 @@ export const FrameBlockInteraction =
|
||||
},
|
||||
};
|
||||
},
|
||||
handleSelection: () => {
|
||||
return {
|
||||
selectable(context) {
|
||||
const { model } = context;
|
||||
|
||||
return (
|
||||
context.default(context) &&
|
||||
(model.isLocked() || !isTransparent(model.props.background))
|
||||
);
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -13,7 +13,6 @@ import { toGfxBlockComponent } from '@blocksuite/std';
|
||||
import {
|
||||
type BoxSelectionContext,
|
||||
GfxViewInteractionExtension,
|
||||
type SelectedContext,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import { html, nothing, type PropertyValues } from 'lit';
|
||||
import { query, state } from 'lit/decorators.js';
|
||||
@@ -342,69 +341,6 @@ export class EdgelessNoteBlockComponent extends toGfxBlockComponent(
|
||||
`;
|
||||
}
|
||||
|
||||
override onSelected(context: SelectedContext) {
|
||||
const { selected, multiSelect, event: e } = context;
|
||||
const { editing } = this.gfx.selection;
|
||||
const alreadySelected = this.gfx.selection.has(this.model.id);
|
||||
|
||||
if (!multiSelect && selected && (alreadySelected || editing)) {
|
||||
if (this.model.isLocked()) return;
|
||||
|
||||
if (alreadySelected && editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.gfx.selection.set({
|
||||
elements: [this.model.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
this.updateComplete
|
||||
.then(() => {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.model.children.length === 0) {
|
||||
const blockId = this.store.addBlock(
|
||||
'affine:paragraph',
|
||||
{ type: 'text' },
|
||||
this.model.id
|
||||
);
|
||||
|
||||
if (blockId) {
|
||||
focusTextModel(this.std, blockId);
|
||||
}
|
||||
} else {
|
||||
const rect = this.querySelector(
|
||||
'.affine-block-children-container'
|
||||
)?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
const offsetY = 8 * this.gfx.viewport.zoom;
|
||||
const offsetX = 2 * this.gfx.viewport.zoom;
|
||||
const x = clamp(
|
||||
e.clientX,
|
||||
rect.left + offsetX,
|
||||
rect.right - offsetX
|
||||
);
|
||||
const y = clamp(
|
||||
e.clientY,
|
||||
rect.top + offsetY,
|
||||
rect.bottom - offsetY
|
||||
);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
} else {
|
||||
handleNativeRangeAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
super.onSelected(context);
|
||||
}
|
||||
}
|
||||
|
||||
override onBoxSelected(_: BoxSelectionContext) {
|
||||
return this.model.props.displayMode !== NoteDisplayMode.DocOnly;
|
||||
}
|
||||
@@ -493,5 +429,71 @@ export const EdgelessNoteInteraction =
|
||||
},
|
||||
};
|
||||
},
|
||||
handleSelection: ({ std, gfx, view, model }) => {
|
||||
return {
|
||||
onSelect(context) {
|
||||
const { selected, multiSelect, event: e } = context;
|
||||
const { editing } = gfx.selection;
|
||||
const alreadySelected = gfx.selection.has(model.id);
|
||||
|
||||
if (!multiSelect && selected && (alreadySelected || editing)) {
|
||||
if (model.isLocked()) return;
|
||||
|
||||
if (alreadySelected && editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
gfx.selection.set({
|
||||
elements: [model.id],
|
||||
editing: true,
|
||||
});
|
||||
|
||||
view.updateComplete
|
||||
.then(() => {
|
||||
if (!view.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (model.children.length === 0) {
|
||||
const blockId = std.store.addBlock(
|
||||
'affine:paragraph',
|
||||
{ type: 'text' },
|
||||
model.id
|
||||
);
|
||||
|
||||
if (blockId) {
|
||||
focusTextModel(std, blockId);
|
||||
}
|
||||
} else {
|
||||
const rect = view
|
||||
.querySelector('.affine-block-children-container')
|
||||
?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
const offsetY = 8 * gfx.viewport.zoom;
|
||||
const offsetX = 2 * gfx.viewport.zoom;
|
||||
const x = clamp(
|
||||
e.clientX,
|
||||
rect.left + offsetX,
|
||||
rect.right - offsetX
|
||||
);
|
||||
const y = clamp(
|
||||
e.clientY,
|
||||
rect.top + offsetY,
|
||||
rect.bottom - offsetY
|
||||
);
|
||||
handleNativeRangeAtPoint(x, y);
|
||||
} else {
|
||||
handleNativeRangeAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
context.default(context);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
GfxController,
|
||||
GfxModel,
|
||||
LayerManager,
|
||||
PointTestOptions,
|
||||
ReorderingDirection,
|
||||
} from '@blocksuite/std/gfx';
|
||||
import {
|
||||
@@ -168,19 +167,6 @@ export class EdgelessRootService
|
||||
this._initReadonlyListener();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is used to pick element in group, if the picked element is in a
|
||||
* group, we will pick the group instead. If that picked group is currently selected, then
|
||||
* we will pick the element itself.
|
||||
*/
|
||||
pickElementInGroup(
|
||||
x: number,
|
||||
y: number,
|
||||
options?: PointTestOptions
|
||||
): GfxModel | null {
|
||||
return this.gfx.getElementInGroup(x, y, options);
|
||||
}
|
||||
|
||||
removeElement(id: string | GfxModel) {
|
||||
id = typeof id === 'string' ? id : id.id;
|
||||
|
||||
|
||||
@@ -154,32 +154,6 @@ export class DefaultTool extends BaseTool {
|
||||
private _determineDragType(evt: PointerEventState): DefaultModeDragType {
|
||||
const { x, y } = this.controller.lastMousePos$.peek();
|
||||
if (this.selection.isInSelectedRect(x, y)) {
|
||||
if (this.selection.selectedElements.length === 1) {
|
||||
const currentHoveredElem = this._getElementInGroup(x, y);
|
||||
let curSelected = this.selection.selectedElements[0];
|
||||
|
||||
// If one of the following condition is true, keep the selection:
|
||||
// 1. if group is currently selected
|
||||
// 2. if the selected element is descendant of the hovered element
|
||||
// 3. not hovering any element or hovering the same element
|
||||
//
|
||||
// Otherwise, we update the selection to the current hovered element
|
||||
const shouldKeepSelection =
|
||||
isGfxGroupCompatibleModel(curSelected) ||
|
||||
(isGfxGroupCompatibleModel(currentHoveredElem) &&
|
||||
currentHoveredElem.hasDescendant(curSelected)) ||
|
||||
!currentHoveredElem ||
|
||||
currentHoveredElem === curSelected;
|
||||
|
||||
if (!shouldKeepSelection) {
|
||||
curSelected = currentHoveredElem;
|
||||
this.selection.set({
|
||||
elements: [curSelected.id],
|
||||
editing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.selection.editing
|
||||
? DefaultModeDragType.NativeEditing
|
||||
: DefaultModeDragType.ContentMoving;
|
||||
@@ -194,17 +168,6 @@ export class DefaultTool extends BaseTool {
|
||||
}
|
||||
}
|
||||
|
||||
private _getElementInGroup(modelX: number, modelY: number) {
|
||||
const tryGetLockedAncestor = (e: GfxModel | null) => {
|
||||
if (e?.isLockedByAncestor()) {
|
||||
return e.groups.findLast(group => group.isLocked());
|
||||
}
|
||||
return e;
|
||||
};
|
||||
|
||||
return tryGetLockedAncestor(this.gfx.getElementInGroup(modelX, modelY));
|
||||
}
|
||||
|
||||
private initializeDragState(
|
||||
dragType: DefaultModeDragType,
|
||||
event: PointerEventState
|
||||
|
||||
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
45
blocksuite/affine/gfx/group/src/interaction-ext.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { GroupElementModel } from '@blocksuite/affine-model';
|
||||
import { InteractivityExtension } from '@blocksuite/std/gfx';
|
||||
|
||||
export class GroupInteractionExtension extends InteractivityExtension {
|
||||
static override key = 'group-selection';
|
||||
|
||||
override mounted(): void {
|
||||
this.action.onElementSelect(context => {
|
||||
const { candidates, suggest } = context;
|
||||
const { activeGroup } = this.gfx.selection;
|
||||
let target = context.target;
|
||||
|
||||
if (activeGroup && activeGroup.hasDescendant(target)) {
|
||||
const groups = target.groups;
|
||||
const activeGroupIdx = groups.indexOf(activeGroup);
|
||||
|
||||
if (activeGroupIdx !== -1) {
|
||||
target =
|
||||
groups
|
||||
.slice(0, activeGroupIdx)
|
||||
.findLast(
|
||||
group =>
|
||||
group instanceof GroupElementModel &&
|
||||
candidates.includes(group)
|
||||
) ?? target;
|
||||
}
|
||||
} else {
|
||||
const groups = target.groups;
|
||||
|
||||
target =
|
||||
groups.findLast(group => {
|
||||
return (
|
||||
group instanceof GroupElementModel && candidates.includes(group)
|
||||
);
|
||||
}) ?? target;
|
||||
}
|
||||
|
||||
if (target !== context.target) {
|
||||
suggest({
|
||||
id: target.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { effects } from './effects';
|
||||
import { GroupElementRendererExtension } from './element-renderer';
|
||||
import { GroupElementView, GroupInteraction } from './element-view';
|
||||
import { GroupInteractionExtension } from './interaction-ext';
|
||||
import { groupToolbarExtension } from './toolbar/config';
|
||||
|
||||
export class GroupViewExtension extends ViewExtensionProvider {
|
||||
@@ -23,6 +24,7 @@ export class GroupViewExtension extends ViewExtensionProvider {
|
||||
if (this.isEdgeless(context.scope)) {
|
||||
context.register(groupToolbarExtension);
|
||||
context.register(GroupInteraction);
|
||||
context.register(GroupInteractionExtension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
type BoxSelectionContext,
|
||||
GfxElementModelView,
|
||||
GfxViewInteractionExtension,
|
||||
type SelectedContext,
|
||||
} from '@blocksuite/std/gfx';
|
||||
|
||||
import { handleLayout } from './utils.js';
|
||||
@@ -335,33 +334,6 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
|
||||
return collapseButton;
|
||||
}
|
||||
|
||||
override onSelected(context: SelectedContext): void | boolean {
|
||||
const { position } = context;
|
||||
const target = this.model.childElements.find(child => {
|
||||
if (child.elementBound.containsPoint([position.x, position.y])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (target) {
|
||||
if (this.model.isLocked()) {
|
||||
return super.onSelected(context);
|
||||
}
|
||||
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(target);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [target.id] });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override onBoxSelected(context: BoxSelectionContext) {
|
||||
const { box } = context;
|
||||
const bound = new Bound(box.x, box.y, box.w, box.h);
|
||||
@@ -383,11 +355,24 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
|
||||
}
|
||||
}
|
||||
|
||||
export const MindMapInteraction = GfxViewInteractionExtension(
|
||||
export const MindMapInteraction = GfxViewInteractionExtension<MindMapView>(
|
||||
MindMapView.type,
|
||||
{
|
||||
resizeConstraint: {
|
||||
allowedHandlers: [],
|
||||
},
|
||||
handleSelection: () => {
|
||||
return {
|
||||
onSelect(context) {
|
||||
const { model } = context;
|
||||
|
||||
if (model.isLocked()) {
|
||||
return context.default(context);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -26,10 +26,7 @@ import { LayerManager } from './layer.js';
|
||||
import type { PointTestOptions } from './model/base.js';
|
||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||
import type { GfxModel } from './model/model.js';
|
||||
import {
|
||||
GfxGroupLikeElementModel,
|
||||
GfxPrimitiveElementModel,
|
||||
} from './model/surface/element-model.js';
|
||||
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||
import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.js';
|
||||
|
||||
@@ -181,54 +178,6 @@ 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,
|
||||
options?: PointTestOptions
|
||||
): GfxModel | null {
|
||||
const selectionManager = this.selection;
|
||||
const results = this.getElementByPoint(x, y, {
|
||||
...options,
|
||||
all: true,
|
||||
});
|
||||
let picked = last(results) ?? null;
|
||||
const { activeGroup } = selectionManager;
|
||||
const first = picked;
|
||||
|
||||
if (activeGroup && picked && activeGroup.hasDescendant(picked)) {
|
||||
let index = results.length - 1;
|
||||
|
||||
while (
|
||||
picked === activeGroup ||
|
||||
(picked instanceof GfxGroupLikeElementModel &&
|
||||
picked.hasDescendant(activeGroup))
|
||||
) {
|
||||
picked = results[--index];
|
||||
}
|
||||
} else if (picked) {
|
||||
let index = results.length - 1;
|
||||
|
||||
while (picked.group instanceof GfxGroupLikeElementModel) {
|
||||
if (--index < 0) {
|
||||
picked = null;
|
||||
break;
|
||||
}
|
||||
picked = results[index];
|
||||
}
|
||||
}
|
||||
|
||||
return (picked ?? first) as GfxModel | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all elements in an area.
|
||||
* @param bound
|
||||
|
||||
@@ -36,7 +36,7 @@ export type {
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectedContext,
|
||||
SelectContext,
|
||||
} from './interactivity/index.js';
|
||||
export {
|
||||
GfxViewEventManager,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ExtensionDragMoveContext,
|
||||
ExtensionDragStartContext,
|
||||
} from '../types/drag.js';
|
||||
import type { ExtensionElementSelectContext } from '../types/select.js';
|
||||
|
||||
export const InteractivityExtensionIdentifier =
|
||||
createIdentifier<InteractivityExtension>('interactivity-extension');
|
||||
@@ -118,6 +119,10 @@ type ActionContextMap = {
|
||||
| undefined
|
||||
>;
|
||||
};
|
||||
elementSelect: {
|
||||
context: ExtensionElementSelectContext;
|
||||
returnType: void;
|
||||
};
|
||||
};
|
||||
|
||||
export class InteractivityActionAPI {
|
||||
@@ -151,6 +156,18 @@ export class InteractivityActionAPI {
|
||||
};
|
||||
}
|
||||
|
||||
onElementSelect(
|
||||
handler: (
|
||||
ctx: ActionContextMap['elementSelect']['context']
|
||||
) => ActionContextMap['elementSelect']['returnType']
|
||||
) {
|
||||
this._handlers['elementSelect'] = handler;
|
||||
|
||||
return () => {
|
||||
return delete this._handlers['elementSelect'];
|
||||
};
|
||||
}
|
||||
|
||||
emit<K extends keyof ActionContextMap>(
|
||||
event: K,
|
||||
context: ActionContextMap[K]['context']
|
||||
|
||||
@@ -15,18 +15,21 @@ import type {
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectableContext,
|
||||
SelectContext,
|
||||
} from '../types/view';
|
||||
|
||||
type ExtendedViewContext<
|
||||
T extends GfxBlockComponent | GfxElementModelView,
|
||||
Context,
|
||||
DefaultReturnType = void,
|
||||
> = {
|
||||
/**
|
||||
* The default function of the interaction.
|
||||
* If the interaction is handled by the extension, the default function will not be executed.
|
||||
* But extension can choose to call the default function by `context.default(context)` if needed.
|
||||
*/
|
||||
default: (context: Context) => void;
|
||||
default: (context: Context) => DefaultReturnType;
|
||||
|
||||
model: T['model'];
|
||||
|
||||
@@ -99,6 +102,19 @@ export type GfxViewInteractionConfig<
|
||||
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
|
||||
): void;
|
||||
};
|
||||
|
||||
handleSelection?: (
|
||||
context: Omit<ViewInteractionHandleContext<T>, 'add' | 'delete'>
|
||||
) => {
|
||||
selectable?: (
|
||||
context: SelectableContext &
|
||||
ExtendedViewContext<T, SelectableContext, boolean>
|
||||
) => boolean;
|
||||
onSelect?: (
|
||||
context: SelectContext &
|
||||
ExtendedViewContext<T, SelectContext, boolean | void>
|
||||
) => boolean | void;
|
||||
};
|
||||
};
|
||||
|
||||
export const GfxViewInteractionIdentifier =
|
||||
|
||||
@@ -29,5 +29,5 @@ export type {
|
||||
RotateEndContext,
|
||||
RotateMoveContext,
|
||||
RotateStartContext,
|
||||
SelectedContext,
|
||||
SelectContext,
|
||||
} from './types/view.js';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type ServiceIdentifier } from '@blocksuite/global/di';
|
||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import last from 'lodash-es/last.js';
|
||||
|
||||
import type { PointerEventState } from '../../event/state/pointer.js';
|
||||
import { getTopElements } from '../../utils/tree.js';
|
||||
@@ -9,6 +10,7 @@ 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 { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
import type { GfxElementModelView } from '../view/view.js';
|
||||
import { createInteractionContext, type SupportedEvents } from './event.js';
|
||||
import {
|
||||
@@ -40,6 +42,7 @@ import type {
|
||||
BoxSelectionContext,
|
||||
ResizeConstraint,
|
||||
RotateConstraint,
|
||||
SelectContext,
|
||||
} from './types/view.js';
|
||||
|
||||
type ExtensionPointerHandler = Exclude<
|
||||
@@ -120,6 +123,112 @@ export class InteractivityManager extends GfxExtension {
|
||||
};
|
||||
}
|
||||
|
||||
private _getSelectionConfig(models: GfxModel[]) {
|
||||
type SelectionHandlers = Required<
|
||||
ReturnType<Required<GfxViewInteractionConfig>['handleSelection']>
|
||||
>;
|
||||
|
||||
const selectionConfigMap = new Map<
|
||||
string,
|
||||
{
|
||||
view: GfxBlockComponent | GfxElementModelView;
|
||||
handlers: SelectionHandlers;
|
||||
defaultHandlers: SelectionHandlers;
|
||||
}
|
||||
>();
|
||||
|
||||
models.forEach(model => {
|
||||
const typeOrFlavour = 'flavour' in model ? model.flavour : model.type;
|
||||
const view = this.gfx.view.get(model);
|
||||
const config = this.std.getOptional(
|
||||
GfxViewInteractionIdentifier(typeOrFlavour)
|
||||
);
|
||||
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionConfig =
|
||||
config?.handleSelection?.({
|
||||
gfx: this.gfx,
|
||||
std: this.std,
|
||||
view,
|
||||
model,
|
||||
}) ?? {};
|
||||
const defaultHandlers = {
|
||||
selectable: () => {
|
||||
return !model.isLockedByAncestor();
|
||||
},
|
||||
onSelect: (context: SelectContext) => {
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(model);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [model.id] });
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
selectionConfigMap.set(model.id, {
|
||||
view,
|
||||
defaultHandlers,
|
||||
handlers: {
|
||||
...defaultHandlers,
|
||||
...selectionConfig,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return selectionConfigMap;
|
||||
}
|
||||
|
||||
private _getSuggestedTarget(context: {
|
||||
candidates: GfxModel[];
|
||||
target: GfxModel;
|
||||
}) {
|
||||
const { candidates, target } = context;
|
||||
|
||||
const suggestedElements: {
|
||||
id: string;
|
||||
priority?: number;
|
||||
}[] = [];
|
||||
const suggest = (element: { id: string; priority?: number }) => {
|
||||
suggestedElements.push(element);
|
||||
};
|
||||
|
||||
const extensions = this.interactExtensions;
|
||||
extensions
|
||||
.values()
|
||||
.toArray()
|
||||
.forEach(ext => {
|
||||
return (ext.action as InteractivityActionAPI).emit('elementSelect', {
|
||||
candidates,
|
||||
target,
|
||||
suggest,
|
||||
});
|
||||
});
|
||||
|
||||
if (suggestedElements.length) {
|
||||
suggestedElements.sort((a, b) => {
|
||||
return (a.priority ?? 0) - (b.priority ?? 0);
|
||||
});
|
||||
|
||||
const suggested = last(suggestedElements) as {
|
||||
id: string;
|
||||
priority?: number;
|
||||
};
|
||||
const elm = this.gfx.getElementById(suggested.id);
|
||||
|
||||
return elm instanceof GfxPrimitiveElementModel ||
|
||||
elm instanceof GfxBlockElementModel
|
||||
? elm
|
||||
: target;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle element selection.
|
||||
* @param evt The pointer event that triggered the selection.
|
||||
@@ -129,32 +238,72 @@ export class InteractivityManager extends GfxExtension {
|
||||
const { raw } = evt;
|
||||
const { gfx } = this;
|
||||
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
|
||||
const picked = this.gfx.getElementInGroup(x, y);
|
||||
let candidates = this.gfx.getElementByPoint(x, y, {
|
||||
all: true,
|
||||
});
|
||||
|
||||
const tryGetLockedAncestor = (e: GfxModel) => {
|
||||
if (e?.isLockedByAncestor()) {
|
||||
return e.groups.findLast(group => group.isLocked()) ?? e;
|
||||
}
|
||||
return e;
|
||||
const selectionConfigs = this._getSelectionConfig(candidates);
|
||||
const context = {
|
||||
multiSelect: raw.shiftKey,
|
||||
event: raw,
|
||||
position: Point.from([x, y]),
|
||||
};
|
||||
|
||||
if (picked) {
|
||||
const lockedElement = tryGetLockedAncestor(picked);
|
||||
candidates = candidates.filter(model => {
|
||||
if (!selectionConfigs.has(model.id)) {
|
||||
return false;
|
||||
}
|
||||
const config = selectionConfigs.get(model.id)!;
|
||||
|
||||
return (
|
||||
selectionConfigs.has(model.id) &&
|
||||
selectionConfigs.get(model.id)?.handlers.selectable({
|
||||
...context,
|
||||
view: config.view,
|
||||
model,
|
||||
default: config.defaultHandlers.selectable as () => boolean,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
{
|
||||
let target = last(candidates);
|
||||
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
target = this._getSuggestedTarget({
|
||||
candidates,
|
||||
target,
|
||||
});
|
||||
|
||||
const config = selectionConfigs.has(target.id)
|
||||
? selectionConfigs.get(target.id)
|
||||
: this._getSelectionConfig([target]).get(target.id);
|
||||
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const multiSelect = raw.shiftKey;
|
||||
const view = gfx.view.get(lockedElement);
|
||||
const context = {
|
||||
selected: multiSelect ? !gfx.selection.has(picked.id) : true,
|
||||
selected: multiSelect ? !gfx.selection.has(target.id) : true,
|
||||
multiSelect,
|
||||
event: raw,
|
||||
position: Point.from([x, y]),
|
||||
fallback: lockedElement !== picked,
|
||||
};
|
||||
|
||||
const selected = view?.onSelected(context);
|
||||
return selected ?? true;
|
||||
}
|
||||
const result = config.handlers.onSelect({
|
||||
...context,
|
||||
selected: multiSelect ? !gfx.selection.has(target.id) : true,
|
||||
view: config.view,
|
||||
model: target,
|
||||
default: config.defaultHandlers.onSelect as () => void,
|
||||
});
|
||||
|
||||
return false;
|
||||
return result ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
handleBoxSelection(context: { box: BoxSelectionContext['box'] }) {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GfxModel } from '../../model/model';
|
||||
|
||||
export type ExtensionElementSelectContext = {
|
||||
/**
|
||||
* The candidate elements for selection.
|
||||
*/
|
||||
candidates: GfxModel[];
|
||||
|
||||
/**
|
||||
* The element which is ready to be selected.
|
||||
*/
|
||||
target: GfxModel;
|
||||
|
||||
/**
|
||||
* Use to change the target element of selection.
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
suggest: (element: {
|
||||
/**
|
||||
* The suggested element id
|
||||
*/
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The priority of the suggestion. If there are multiple suggestions coming from different extensions,
|
||||
* the one with the highest priority will be used.
|
||||
*
|
||||
* Default to 0.
|
||||
*/
|
||||
priority?: number;
|
||||
}) => void;
|
||||
};
|
||||
@@ -126,12 +126,7 @@ export type RotateMoveContext = RotateStartContext & {
|
||||
|
||||
export type RotateEndContext = RotateStartContext;
|
||||
|
||||
export type SelectedContext = {
|
||||
/**
|
||||
* The selected state of the element
|
||||
*/
|
||||
selected: boolean;
|
||||
|
||||
export type SelectableContext = {
|
||||
/**
|
||||
* Whether is multi-select, usually triggered by shift key
|
||||
*/
|
||||
@@ -146,14 +141,13 @@ export type SelectedContext = {
|
||||
* The model position of the event pointer
|
||||
*/
|
||||
position: IPoint;
|
||||
};
|
||||
|
||||
export type SelectContext = SelectableContext & {
|
||||
/**
|
||||
* If the current selection is a fallback selection.
|
||||
*
|
||||
* E.g., if selecting a child element inside a group, the `onSelected` method will be executed on group, and
|
||||
* the fallback is true because the it's not the original target(the child element).
|
||||
* The selected state of the element
|
||||
*/
|
||||
fallback: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type BoxSelectionContext = {
|
||||
@@ -172,11 +166,6 @@ export type GfxViewTransformInterface = {
|
||||
onDragMove: (context: DragMoveContext) => void;
|
||||
onDragEnd: (context: DragEndContext) => void;
|
||||
|
||||
/**
|
||||
* When the element is selected by the pointer
|
||||
*/
|
||||
onSelected: (context: SelectedContext) => void;
|
||||
|
||||
/**
|
||||
* When the element is selected by box selection, return false to prevent the default selection behavior.
|
||||
*/
|
||||
|
||||
@@ -626,6 +626,11 @@ export class SurfaceBlockModel extends BlockModel<SurfaceBlockProps> {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all groups in the group chain. The last group is the top level group.
|
||||
* @param id
|
||||
* @returns
|
||||
*/
|
||||
getGroups(id: string): GfxGroupModel[] {
|
||||
const groups: GfxGroupModel[] = [];
|
||||
const visited = new Set<GfxGroupModel>();
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
DragMoveContext,
|
||||
DragStartContext,
|
||||
GfxViewTransformInterface,
|
||||
SelectedContext,
|
||||
} from '../interactivity/index.js';
|
||||
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
|
||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||
@@ -210,18 +209,6 @@ export class GfxElementModelView<
|
||||
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
|
||||
}
|
||||
|
||||
onSelected(context: SelectedContext): void | boolean {
|
||||
if (this.model instanceof GfxPrimitiveElementModel) {
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(this.model);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [this.model.id] });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext): boolean | void {}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
BoxSelectionContext,
|
||||
DragMoveContext,
|
||||
GfxViewTransformInterface,
|
||||
SelectedContext,
|
||||
} from '../../gfx/interactivity/index.js';
|
||||
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
||||
import { SurfaceSelection } from '../../selection/index.js';
|
||||
@@ -104,16 +103,6 @@ export abstract class GfxBlockComponent<
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
|
||||
onSelected(context: SelectedContext): void | boolean {
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(this.model);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [this.model.id] });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext) {}
|
||||
|
||||
getCSSTransform() {
|
||||
@@ -219,17 +208,6 @@ export function toGfxBlockComponent<
|
||||
this.model.pop('xywh');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
onSelected(context: SelectedContext): void | boolean {
|
||||
if (context.multiSelect) {
|
||||
this.gfx.selection.toggle(this.model);
|
||||
} else {
|
||||
this.gfx.selection.set({ elements: [this.model.id] });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
onBoxSelected(_: BoxSelectionContext) {}
|
||||
|
||||
get gfx() {
|
||||
|
||||
Reference in New Issue
Block a user