mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-22 20:41:50 +08: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,
|
GfxBlockComponent,
|
||||||
TextSelection,
|
TextSelection,
|
||||||
} from '@blocksuite/std';
|
} from '@blocksuite/std';
|
||||||
import {
|
import { GfxViewInteractionExtension } from '@blocksuite/std/gfx';
|
||||||
GfxViewInteractionExtension,
|
|
||||||
type SelectedContext,
|
|
||||||
} from '@blocksuite/std/gfx';
|
|
||||||
import { computed } from '@preact/signals-core';
|
import { computed } from '@preact/signals-core';
|
||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
import { query, state } from 'lit/decorators.js';
|
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() {
|
override renderGfxBlock() {
|
||||||
const { model } = this;
|
const { model } = this;
|
||||||
const { rotate, hasMaxWidth } = model.props;
|
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,
|
DefaultTheme,
|
||||||
type FrameBlockModel,
|
type FrameBlockModel,
|
||||||
FrameBlockSchema,
|
FrameBlockSchema,
|
||||||
|
isTransparent,
|
||||||
} from '@blocksuite/affine-model';
|
} from '@blocksuite/affine-model';
|
||||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||||
import { Bound } from '@blocksuite/global/gfx';
|
import { Bound } from '@blocksuite/global/gfx';
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
type BoxSelectionContext,
|
type BoxSelectionContext,
|
||||||
getTopElements,
|
getTopElements,
|
||||||
GfxViewInteractionExtension,
|
GfxViewInteractionExtension,
|
||||||
type SelectedContext,
|
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { html } from 'lit';
|
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) {
|
override onBoxSelected(context: BoxSelectionContext) {
|
||||||
const { box } = context;
|
const { box } = context;
|
||||||
const bound = new Bound(box.x, box.y, box.w, box.h);
|
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 {
|
import {
|
||||||
type BoxSelectionContext,
|
type BoxSelectionContext,
|
||||||
GfxViewInteractionExtension,
|
GfxViewInteractionExtension,
|
||||||
type SelectedContext,
|
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
import { html, nothing, type PropertyValues } from 'lit';
|
import { html, nothing, type PropertyValues } from 'lit';
|
||||||
import { query, state } from 'lit/decorators.js';
|
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) {
|
override onBoxSelected(_: BoxSelectionContext) {
|
||||||
return this.model.props.displayMode !== NoteDisplayMode.DocOnly;
|
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,
|
GfxController,
|
||||||
GfxModel,
|
GfxModel,
|
||||||
LayerManager,
|
LayerManager,
|
||||||
PointTestOptions,
|
|
||||||
ReorderingDirection,
|
ReorderingDirection,
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
import {
|
import {
|
||||||
@@ -168,19 +167,6 @@ export class EdgelessRootService
|
|||||||
this._initReadonlyListener();
|
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) {
|
removeElement(id: string | GfxModel) {
|
||||||
id = typeof id === 'string' ? id : id.id;
|
id = typeof id === 'string' ? id : id.id;
|
||||||
|
|
||||||
|
|||||||
@@ -154,32 +154,6 @@ export class DefaultTool extends BaseTool {
|
|||||||
private _determineDragType(evt: PointerEventState): DefaultModeDragType {
|
private _determineDragType(evt: PointerEventState): DefaultModeDragType {
|
||||||
const { x, y } = this.controller.lastMousePos$.peek();
|
const { x, y } = this.controller.lastMousePos$.peek();
|
||||||
if (this.selection.isInSelectedRect(x, y)) {
|
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
|
return this.selection.editing
|
||||||
? DefaultModeDragType.NativeEditing
|
? DefaultModeDragType.NativeEditing
|
||||||
: DefaultModeDragType.ContentMoving;
|
: 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(
|
private initializeDragState(
|
||||||
dragType: DefaultModeDragType,
|
dragType: DefaultModeDragType,
|
||||||
event: PointerEventState
|
event: PointerEventState
|
||||||
|
|||||||
@@ -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 { effects } from './effects';
|
||||||
import { GroupElementRendererExtension } from './element-renderer';
|
import { GroupElementRendererExtension } from './element-renderer';
|
||||||
import { GroupElementView, GroupInteraction } from './element-view';
|
import { GroupElementView, GroupInteraction } from './element-view';
|
||||||
|
import { GroupInteractionExtension } from './interaction-ext';
|
||||||
import { groupToolbarExtension } from './toolbar/config';
|
import { groupToolbarExtension } from './toolbar/config';
|
||||||
|
|
||||||
export class GroupViewExtension extends ViewExtensionProvider {
|
export class GroupViewExtension extends ViewExtensionProvider {
|
||||||
@@ -23,6 +24,7 @@ export class GroupViewExtension extends ViewExtensionProvider {
|
|||||||
if (this.isEdgeless(context.scope)) {
|
if (this.isEdgeless(context.scope)) {
|
||||||
context.register(groupToolbarExtension);
|
context.register(groupToolbarExtension);
|
||||||
context.register(GroupInteraction);
|
context.register(GroupInteraction);
|
||||||
|
context.register(GroupInteractionExtension);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
type BoxSelectionContext,
|
type BoxSelectionContext,
|
||||||
GfxElementModelView,
|
GfxElementModelView,
|
||||||
GfxViewInteractionExtension,
|
GfxViewInteractionExtension,
|
||||||
type SelectedContext,
|
|
||||||
} from '@blocksuite/std/gfx';
|
} from '@blocksuite/std/gfx';
|
||||||
|
|
||||||
import { handleLayout } from './utils.js';
|
import { handleLayout } from './utils.js';
|
||||||
@@ -335,33 +334,6 @@ export class MindMapView extends GfxElementModelView<MindmapElementModel> {
|
|||||||
return collapseButton;
|
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) {
|
override onBoxSelected(context: BoxSelectionContext) {
|
||||||
const { box } = context;
|
const { box } = context;
|
||||||
const bound = new Bound(box.x, box.y, box.w, box.h);
|
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,
|
MindMapView.type,
|
||||||
{
|
{
|
||||||
resizeConstraint: {
|
resizeConstraint: {
|
||||||
allowedHandlers: [],
|
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 type { PointTestOptions } from './model/base.js';
|
||||||
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
import { GfxBlockElementModel } from './model/gfx-block-model.js';
|
||||||
import type { GfxModel } from './model/model.js';
|
import type { GfxModel } from './model/model.js';
|
||||||
import {
|
import { GfxPrimitiveElementModel } from './model/surface/element-model.js';
|
||||||
GfxGroupLikeElementModel,
|
|
||||||
GfxPrimitiveElementModel,
|
|
||||||
} from './model/surface/element-model.js';
|
|
||||||
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
|
import type { SurfaceBlockModel } from './model/surface/surface-model.js';
|
||||||
import { FIT_TO_SCREEN_PADDING, Viewport, ZOOM_INITIAL } from './viewport.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;
|
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.
|
* Query all elements in an area.
|
||||||
* @param bound
|
* @param bound
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export type {
|
|||||||
RotateEndContext,
|
RotateEndContext,
|
||||||
RotateMoveContext,
|
RotateMoveContext,
|
||||||
RotateStartContext,
|
RotateStartContext,
|
||||||
SelectedContext,
|
SelectContext,
|
||||||
} from './interactivity/index.js';
|
} from './interactivity/index.js';
|
||||||
export {
|
export {
|
||||||
GfxViewEventManager,
|
GfxViewEventManager,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
ExtensionDragMoveContext,
|
ExtensionDragMoveContext,
|
||||||
ExtensionDragStartContext,
|
ExtensionDragStartContext,
|
||||||
} from '../types/drag.js';
|
} from '../types/drag.js';
|
||||||
|
import type { ExtensionElementSelectContext } from '../types/select.js';
|
||||||
|
|
||||||
export const InteractivityExtensionIdentifier =
|
export const InteractivityExtensionIdentifier =
|
||||||
createIdentifier<InteractivityExtension>('interactivity-extension');
|
createIdentifier<InteractivityExtension>('interactivity-extension');
|
||||||
@@ -118,6 +119,10 @@ type ActionContextMap = {
|
|||||||
| undefined
|
| undefined
|
||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
elementSelect: {
|
||||||
|
context: ExtensionElementSelectContext;
|
||||||
|
returnType: void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export class InteractivityActionAPI {
|
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>(
|
emit<K extends keyof ActionContextMap>(
|
||||||
event: K,
|
event: K,
|
||||||
context: ActionContextMap[K]['context']
|
context: ActionContextMap[K]['context']
|
||||||
|
|||||||
@@ -15,18 +15,21 @@ import type {
|
|||||||
RotateEndContext,
|
RotateEndContext,
|
||||||
RotateMoveContext,
|
RotateMoveContext,
|
||||||
RotateStartContext,
|
RotateStartContext,
|
||||||
|
SelectableContext,
|
||||||
|
SelectContext,
|
||||||
} from '../types/view';
|
} from '../types/view';
|
||||||
|
|
||||||
type ExtendedViewContext<
|
type ExtendedViewContext<
|
||||||
T extends GfxBlockComponent | GfxElementModelView,
|
T extends GfxBlockComponent | GfxElementModelView,
|
||||||
Context,
|
Context,
|
||||||
|
DefaultReturnType = void,
|
||||||
> = {
|
> = {
|
||||||
/**
|
/**
|
||||||
* The default function of the interaction.
|
* The default function of the interaction.
|
||||||
* If the interaction is handled by the extension, the default function will not be executed.
|
* 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.
|
* 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'];
|
model: T['model'];
|
||||||
|
|
||||||
@@ -99,6 +102,19 @@ export type GfxViewInteractionConfig<
|
|||||||
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
|
context: RotateEndContext & ExtendedViewContext<T, RotateEndContext>
|
||||||
): void;
|
): 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 =
|
export const GfxViewInteractionIdentifier =
|
||||||
|
|||||||
@@ -29,5 +29,5 @@ export type {
|
|||||||
RotateEndContext,
|
RotateEndContext,
|
||||||
RotateMoveContext,
|
RotateMoveContext,
|
||||||
RotateStartContext,
|
RotateStartContext,
|
||||||
SelectedContext,
|
SelectContext,
|
||||||
} from './types/view.js';
|
} from './types/view.js';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type ServiceIdentifier } from '@blocksuite/global/di';
|
|||||||
import { DisposableGroup } from '@blocksuite/global/disposable';
|
import { DisposableGroup } from '@blocksuite/global/disposable';
|
||||||
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
|
import { Bound, clamp, Point } from '@blocksuite/global/gfx';
|
||||||
import { signal } from '@preact/signals-core';
|
import { signal } from '@preact/signals-core';
|
||||||
|
import last from 'lodash-es/last.js';
|
||||||
|
|
||||||
import type { PointerEventState } from '../../event/state/pointer.js';
|
import type { PointerEventState } from '../../event/state/pointer.js';
|
||||||
import { getTopElements } from '../../utils/tree.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 { GfxExtension, GfxExtensionIdentifier } from '../extension.js';
|
||||||
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
import { GfxBlockElementModel } from '../model/gfx-block-model.js';
|
||||||
import type { GfxModel } from '../model/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 type { GfxElementModelView } from '../view/view.js';
|
||||||
import { createInteractionContext, type SupportedEvents } from './event.js';
|
import { createInteractionContext, type SupportedEvents } from './event.js';
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +42,7 @@ import type {
|
|||||||
BoxSelectionContext,
|
BoxSelectionContext,
|
||||||
ResizeConstraint,
|
ResizeConstraint,
|
||||||
RotateConstraint,
|
RotateConstraint,
|
||||||
|
SelectContext,
|
||||||
} from './types/view.js';
|
} from './types/view.js';
|
||||||
|
|
||||||
type ExtensionPointerHandler = Exclude<
|
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.
|
* Handle element selection.
|
||||||
* @param evt The pointer event that triggered the selection.
|
* @param evt The pointer event that triggered the selection.
|
||||||
@@ -129,32 +238,72 @@ export class InteractivityManager extends GfxExtension {
|
|||||||
const { raw } = evt;
|
const { raw } = evt;
|
||||||
const { gfx } = this;
|
const { gfx } = this;
|
||||||
const [x, y] = gfx.viewport.toModelCoordFromClientCoord([raw.x, raw.y]);
|
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) => {
|
const selectionConfigs = this._getSelectionConfig(candidates);
|
||||||
if (e?.isLockedByAncestor()) {
|
const context = {
|
||||||
return e.groups.findLast(group => group.isLocked()) ?? e;
|
multiSelect: raw.shiftKey,
|
||||||
}
|
event: raw,
|
||||||
return e;
|
position: Point.from([x, y]),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (picked) {
|
candidates = candidates.filter(model => {
|
||||||
const lockedElement = tryGetLockedAncestor(picked);
|
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 multiSelect = raw.shiftKey;
|
||||||
const view = gfx.view.get(lockedElement);
|
|
||||||
const context = {
|
const context = {
|
||||||
selected: multiSelect ? !gfx.selection.has(picked.id) : true,
|
selected: multiSelect ? !gfx.selection.has(target.id) : true,
|
||||||
multiSelect,
|
multiSelect,
|
||||||
event: raw,
|
event: raw,
|
||||||
position: Point.from([x, y]),
|
position: Point.from([x, y]),
|
||||||
fallback: lockedElement !== picked,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const selected = view?.onSelected(context);
|
const result = config.handlers.onSelect({
|
||||||
return selected ?? true;
|
...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'] }) {
|
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 RotateEndContext = RotateStartContext;
|
||||||
|
|
||||||
export type SelectedContext = {
|
export type SelectableContext = {
|
||||||
/**
|
|
||||||
* The selected state of the element
|
|
||||||
*/
|
|
||||||
selected: boolean;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether is multi-select, usually triggered by shift key
|
* Whether is multi-select, usually triggered by shift key
|
||||||
*/
|
*/
|
||||||
@@ -146,14 +141,13 @@ export type SelectedContext = {
|
|||||||
* The model position of the event pointer
|
* The model position of the event pointer
|
||||||
*/
|
*/
|
||||||
position: IPoint;
|
position: IPoint;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SelectContext = SelectableContext & {
|
||||||
/**
|
/**
|
||||||
* If the current selection is a fallback selection.
|
* The selected state of the element
|
||||||
*
|
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
fallback: boolean;
|
selected: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BoxSelectionContext = {
|
export type BoxSelectionContext = {
|
||||||
@@ -172,11 +166,6 @@ export type GfxViewTransformInterface = {
|
|||||||
onDragMove: (context: DragMoveContext) => void;
|
onDragMove: (context: DragMoveContext) => void;
|
||||||
onDragEnd: (context: DragEndContext) => 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.
|
* 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all groups in the group chain. The last group is the top level group.
|
||||||
|
* @param id
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
getGroups(id: string): GfxGroupModel[] {
|
getGroups(id: string): GfxGroupModel[] {
|
||||||
const groups: GfxGroupModel[] = [];
|
const groups: GfxGroupModel[] = [];
|
||||||
const visited = new Set<GfxGroupModel>();
|
const visited = new Set<GfxGroupModel>();
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
DragMoveContext,
|
DragMoveContext,
|
||||||
DragStartContext,
|
DragStartContext,
|
||||||
GfxViewTransformInterface,
|
GfxViewTransformInterface,
|
||||||
SelectedContext,
|
|
||||||
} from '../interactivity/index.js';
|
} from '../interactivity/index.js';
|
||||||
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
|
import type { GfxElementGeometry, PointTestOptions } from '../model/base.js';
|
||||||
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
import { GfxPrimitiveElementModel } from '../model/surface/element-model.js';
|
||||||
@@ -210,18 +209,6 @@ export class GfxElementModelView<
|
|||||||
this.model.xywh = currentBound.moveDelta(dx, dy).serialize();
|
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 {}
|
onBoxSelected(_: BoxSelectionContext): boolean | void {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
BoxSelectionContext,
|
BoxSelectionContext,
|
||||||
DragMoveContext,
|
DragMoveContext,
|
||||||
GfxViewTransformInterface,
|
GfxViewTransformInterface,
|
||||||
SelectedContext,
|
|
||||||
} from '../../gfx/interactivity/index.js';
|
} from '../../gfx/interactivity/index.js';
|
||||||
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
import type { GfxBlockElementModel } from '../../gfx/model/gfx-block-model.js';
|
||||||
import { SurfaceSelection } from '../../selection/index.js';
|
import { SurfaceSelection } from '../../selection/index.js';
|
||||||
@@ -104,16 +103,6 @@ export abstract class GfxBlockComponent<
|
|||||||
this.model.pop('xywh');
|
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) {}
|
onBoxSelected(_: BoxSelectionContext) {}
|
||||||
|
|
||||||
getCSSTransform() {
|
getCSSTransform() {
|
||||||
@@ -219,17 +208,6 @@ export function toGfxBlockComponent<
|
|||||||
this.model.pop('xywh');
|
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) {}
|
onBoxSelected(_: BoxSelectionContext) {}
|
||||||
|
|
||||||
get gfx() {
|
get gfx() {
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ import {
|
|||||||
zoomResetByKeyboard,
|
zoomResetByKeyboard,
|
||||||
} from '../../utils/actions/edgeless.js';
|
} from '../../utils/actions/edgeless.js';
|
||||||
import {
|
import {
|
||||||
pressBackspace,
|
|
||||||
pressEnter,
|
|
||||||
pressEscape,
|
pressEscape,
|
||||||
selectAllByKeyboard,
|
selectAllByKeyboard,
|
||||||
type,
|
type,
|
||||||
@@ -57,21 +55,6 @@ test.describe('frame selection', () => {
|
|||||||
expect(await getSelectedBoundCount(page)).toBe(0);
|
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 }) => {
|
test('frame can be selected by click frame title', async ({ page }) => {
|
||||||
const frame = await createFrame(page, [50, 50], [150, 150]);
|
const frame = await createFrame(page, [50, 50], [150, 150]);
|
||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
@@ -84,6 +67,59 @@ test.describe('frame selection', () => {
|
|||||||
await assertSelectedBound(page, [50, 50, 100, 100]);
|
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 ({
|
test('frame can be selected by click frame title when a note overlap on it', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -112,8 +148,10 @@ test.describe('frame selection', () => {
|
|||||||
expect(selectedIds[0]).toBe(frame);
|
expect(selectedIds[0]).toBe(frame);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shape inside frame can be selected and edited', async ({ page }) => {
|
test('shape inside frame can be directly selected and edited', async ({
|
||||||
await createFrame(page, [50, 50], [150, 150]);
|
page,
|
||||||
|
}) => {
|
||||||
|
const frame = await createFrame(page, [50, 50], [250, 250]);
|
||||||
await createShapeElement(page, [100, 100], [200, 200], Shape.Square);
|
await createShapeElement(page, [100, 100], [200, 200], Shape.Square);
|
||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
|
|
||||||
@@ -121,6 +159,30 @@ test.describe('frame selection', () => {
|
|||||||
expect(await getSelectedBoundCount(page)).toBe(1);
|
expect(await getSelectedBoundCount(page)).toBe(1);
|
||||||
await assertSelectedBound(page, [100, 100, 100, 100]);
|
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 dblclickView(page, [150, 150]);
|
||||||
await type(page, 'hello');
|
await type(page, 'hello');
|
||||||
await assertEdgelessCanvasText(page, 'hello');
|
await assertEdgelessCanvasText(page, 'hello');
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ test.describe('lock', () => {
|
|||||||
await lock.click();
|
await lock.click();
|
||||||
await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]); // frame outline and shape
|
await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]); // frame outline and shape
|
||||||
await pressEscape(page);
|
await pressEscape(page);
|
||||||
await clickView(page, [100, 100]);
|
await clickView(page, [90, 90]);
|
||||||
await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]);
|
await assertEdgelessSelectedModelRect(page, [0, 0, 125, 125]);
|
||||||
|
|
||||||
await unlock.click();
|
await unlock.click();
|
||||||
|
|||||||
Reference in New Issue
Block a user