mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 04:48:53 +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:
@@ -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