diff --git a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts index e6b0a467a..4665e01e7 100644 --- a/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts +++ b/blocksuite/affine/blocks/edgeless-text/src/edgeless-text-block.ts @@ -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 { - 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); + } + }, + }; + }, } ); diff --git a/blocksuite/affine/blocks/frame/src/frame-block.ts b/blocksuite/affine/blocks/frame/src/frame-block.ts index 213e392ac..1fbc44710 100644 --- a/blocksuite/affine/blocks/frame/src/frame-block.ts +++ b/blocksuite/affine/blocks/frame/src/frame-block.ts @@ -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 { }; } - 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)) + ); + }, + }; + }, } ); diff --git a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts index 3dda2fbf0..a75699cc9 100644 --- a/blocksuite/affine/blocks/note/src/note-edgeless-block.ts +++ b/blocksuite/affine/blocks/note/src/note-edgeless-block.ts @@ -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); + } + }, + }; + }, } ); diff --git a/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-service.ts b/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-service.ts index 3a06ea7e9..f600bae7d 100644 --- a/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-service.ts +++ b/blocksuite/affine/blocks/root/src/edgeless/edgeless-root-service.ts @@ -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; diff --git a/blocksuite/affine/blocks/surface/src/tool/default-tool.ts b/blocksuite/affine/blocks/surface/src/tool/default-tool.ts index 21b57943c..70ca5f00e 100644 --- a/blocksuite/affine/blocks/surface/src/tool/default-tool.ts +++ b/blocksuite/affine/blocks/surface/src/tool/default-tool.ts @@ -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 diff --git a/blocksuite/affine/gfx/group/src/interaction-ext.ts b/blocksuite/affine/gfx/group/src/interaction-ext.ts new file mode 100644 index 000000000..97c50e182 --- /dev/null +++ b/blocksuite/affine/gfx/group/src/interaction-ext.ts @@ -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, + }); + } + }); + } +} diff --git a/blocksuite/affine/gfx/group/src/view.ts b/blocksuite/affine/gfx/group/src/view.ts index 82445acaa..8e6236758 100644 --- a/blocksuite/affine/gfx/group/src/view.ts +++ b/blocksuite/affine/gfx/group/src/view.ts @@ -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); } } } diff --git a/blocksuite/affine/gfx/mindmap/src/view/view.ts b/blocksuite/affine/gfx/mindmap/src/view/view.ts index 1187527de..e354a9bba 100644 --- a/blocksuite/affine/gfx/mindmap/src/view/view.ts +++ b/blocksuite/affine/gfx/mindmap/src/view/view.ts @@ -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 { 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 { } } -export const MindMapInteraction = GfxViewInteractionExtension( +export const MindMapInteraction = GfxViewInteractionExtension( MindMapView.type, { resizeConstraint: { allowedHandlers: [], }, + handleSelection: () => { + return { + onSelect(context) { + const { model } = context; + + if (model.isLocked()) { + return context.default(context); + } + + return false; + }, + }; + }, } ); diff --git a/blocksuite/framework/std/src/gfx/controller.ts b/blocksuite/framework/std/src/gfx/controller.ts index 2285e1d17..d95899d19 100644 --- a/blocksuite/framework/std/src/gfx/controller.ts +++ b/blocksuite/framework/std/src/gfx/controller.ts @@ -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 diff --git a/blocksuite/framework/std/src/gfx/index.ts b/blocksuite/framework/std/src/gfx/index.ts index a0bb03046..7ec77a243 100644 --- a/blocksuite/framework/std/src/gfx/index.ts +++ b/blocksuite/framework/std/src/gfx/index.ts @@ -36,7 +36,7 @@ export type { RotateEndContext, RotateMoveContext, RotateStartContext, - SelectedContext, + SelectContext, } from './interactivity/index.js'; export { GfxViewEventManager, diff --git a/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts b/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts index e7e80a1a5..1b624b69c 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/extension/base.ts @@ -13,6 +13,7 @@ import type { ExtensionDragMoveContext, ExtensionDragStartContext, } from '../types/drag.js'; +import type { ExtensionElementSelectContext } from '../types/select.js'; export const InteractivityExtensionIdentifier = createIdentifier('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( event: K, context: ActionContextMap[K]['context'] diff --git a/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts b/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts index 82e130626..62d7a29fa 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/extension/view.ts @@ -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 ): void; }; + + handleSelection?: ( + context: Omit, 'add' | 'delete'> + ) => { + selectable?: ( + context: SelectableContext & + ExtendedViewContext + ) => boolean; + onSelect?: ( + context: SelectContext & + ExtendedViewContext + ) => boolean | void; + }; }; export const GfxViewInteractionIdentifier = diff --git a/blocksuite/framework/std/src/gfx/interactivity/index.ts b/blocksuite/framework/std/src/gfx/interactivity/index.ts index 1fcdfd3c4..2956b30a0 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/index.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/index.ts @@ -29,5 +29,5 @@ export type { RotateEndContext, RotateMoveContext, RotateStartContext, - SelectedContext, + SelectContext, } from './types/view.js'; diff --git a/blocksuite/framework/std/src/gfx/interactivity/manager.ts b/blocksuite/framework/std/src/gfx/interactivity/manager.ts index cbb223bca..703739efb 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/manager.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/manager.ts @@ -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['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'] }) { diff --git a/blocksuite/framework/std/src/gfx/interactivity/types/select.ts b/blocksuite/framework/std/src/gfx/interactivity/types/select.ts new file mode 100644 index 000000000..13223997a --- /dev/null +++ b/blocksuite/framework/std/src/gfx/interactivity/types/select.ts @@ -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; +}; diff --git a/blocksuite/framework/std/src/gfx/interactivity/types/view.ts b/blocksuite/framework/std/src/gfx/interactivity/types/view.ts index 87b77baca..084902d4b 100644 --- a/blocksuite/framework/std/src/gfx/interactivity/types/view.ts +++ b/blocksuite/framework/std/src/gfx/interactivity/types/view.ts @@ -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. */ diff --git a/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts b/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts index 35f1153bb..c8c5b20b9 100644 --- a/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts +++ b/blocksuite/framework/std/src/gfx/model/surface/surface-model.ts @@ -626,6 +626,11 @@ export class SurfaceBlockModel extends BlockModel { 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(); diff --git a/blocksuite/framework/std/src/gfx/view/view.ts b/blocksuite/framework/std/src/gfx/view/view.ts index ae934c923..03f3306b4 100644 --- a/blocksuite/framework/std/src/gfx/view/view.ts +++ b/blocksuite/framework/std/src/gfx/view/view.ts @@ -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 {} /** diff --git a/blocksuite/framework/std/src/view/element/gfx-block-component.ts b/blocksuite/framework/std/src/view/element/gfx-block-component.ts index 117fffc90..fc0edf9f3 100644 --- a/blocksuite/framework/std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/std/src/view/element/gfx-block-component.ts @@ -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() { diff --git a/tests/blocksuite/e2e/edgeless/frame/selection.spec.ts b/tests/blocksuite/e2e/edgeless/frame/selection.spec.ts index 5a8ab47e3..b74dcd84f 100644 --- a/tests/blocksuite/e2e/edgeless/frame/selection.spec.ts +++ b/tests/blocksuite/e2e/edgeless/frame/selection.spec.ts @@ -16,8 +16,6 @@ import { zoomResetByKeyboard, } from '../../utils/actions/edgeless.js'; import { - pressBackspace, - pressEnter, pressEscape, selectAllByKeyboard, type, @@ -57,21 +55,6 @@ test.describe('frame selection', () => { expect(await getSelectedBoundCount(page)).toBe(0); }); - test('frame can selected by click blank area of frame if it has not title', async ({ - page, - }) => { - await createFrame(page, [50, 50], [150, 150]); - await pressEscape(page); - expect(await getSelectedBoundCount(page)).toBe(0); - - await page.locator('affine-frame-title').dblclick(); - await pressBackspace(page); - await pressEnter(page); - - await clickView(page, [100, 100]); - expect(await getSelectedBoundCount(page)).toBe(1); - }); - test('frame can be selected by click frame title', async ({ page }) => { const frame = await createFrame(page, [50, 50], [150, 150]); await pressEscape(page); @@ -84,6 +67,59 @@ test.describe('frame selection', () => { await assertSelectedBound(page, [50, 50, 100, 100]); }); + test('frame can be selected by body only if frame is locked or its background is not transparent', async ({ + page, + }) => { + const frame = await createFrame(page, [50, 50], [150, 150]); + await pressEscape(page); + expect(await getSelectedBoundCount(page)).toBe(0); + + const frameTitle = getFrameTitle(page, frame); + await frameTitle.click(); + + const getButtons = (page: Page) => { + const toolbar = page.locator('affine-toolbar-widget'); + return { + lock: toolbar.getByTestId('lock'), + unlock: toolbar.getByTestId('unlock'), + }; + }; + + const { lock, unlock } = await getButtons(page); + await lock.click(); + + await pressEscape(page); + expect(await getSelectedBoundCount(page)).toBe(0); + + // click body should select + await clickView(page, [100, 100]); + expect(await getSelectedBoundCount(page)).toBe(1); + + await unlock.click(); + // set frame background to non-transparent value + await page.evaluate(() => { + const edgelessRoot = document.querySelector('affine-edgeless-root'); + + if (!edgelessRoot) { + throw new Error('Not edgeless root found'); + } + + const gfx = edgelessRoot.gfx; + + gfx.std.store.transact(() => { + // @ts-expect-error set frame block background + gfx.selection.selectedElements[0].props.background = '#fb7081'; + }); + }); + + await pressEscape(page); + expect(await getSelectedBoundCount(page)).toBe(0); + + // click body should select + await clickView(page, [100, 100]); + expect(await getSelectedBoundCount(page)).toBe(1); + }); + test('frame can be selected by click frame title when a note overlap on it', async ({ page, }) => { @@ -112,8 +148,10 @@ test.describe('frame selection', () => { expect(selectedIds[0]).toBe(frame); }); - test('shape inside frame can be selected and edited', async ({ page }) => { - await createFrame(page, [50, 50], [150, 150]); + test('shape inside frame can be directly selected and edited', async ({ + page, + }) => { + const frame = await createFrame(page, [50, 50], [250, 250]); await createShapeElement(page, [100, 100], [200, 200], Shape.Square); await pressEscape(page); @@ -121,6 +159,30 @@ test.describe('frame selection', () => { expect(await getSelectedBoundCount(page)).toBe(1); await assertSelectedBound(page, [100, 100, 100, 100]); + // select frame to change its background + const frameTitle = getFrameTitle(page, frame); + await frameTitle.click(); + // set frame background to non-transparent value + await page.evaluate(() => { + const edgelessRoot = document.querySelector('affine-edgeless-root'); + + if (!edgelessRoot) { + throw new Error('Not edgeless root found'); + } + + const gfx = edgelessRoot.gfx; + + gfx.std.store.transact(() => { + // @ts-expect-error set frame block background + gfx.selection.selectedElements[0].props.background = '#fb7081'; + }); + }); + await pressEscape(page); + expect(await getSelectedBoundCount(page)).toBe(0); + await clickView(page, [150, 150]); + expect(await getSelectedBoundCount(page)).toBe(1); + await assertSelectedBound(page, [100, 100, 100, 100]); + await dblclickView(page, [150, 150]); await type(page, 'hello'); await assertEdgelessCanvasText(page, 'hello'); diff --git a/tests/blocksuite/e2e/edgeless/lock.spec.ts b/tests/blocksuite/e2e/edgeless/lock.spec.ts index 51eec1266..5f78ad483 100644 --- a/tests/blocksuite/e2e/edgeless/lock.spec.ts +++ b/tests/blocksuite/e2e/edgeless/lock.spec.ts @@ -194,7 +194,7 @@ test.describe('lock', () => { await lock.click(); await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]); // frame outline and shape await pressEscape(page); - await clickView(page, [100, 100]); + await clickView(page, [90, 90]); await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]); await unlock.click();