From 1c8d25bc297839261e28ef9e7e14c62f7b57ddbb Mon Sep 17 00:00:00 2001 From: doouding Date: Wed, 19 Mar 2025 15:30:06 +0000 Subject: [PATCH] feat: add ElementTransformManager for edgeless element basic manipulation (#10824) ### Overview: We've been working with some legacy code in the default-tool and edgeless-selected-rect modules, which are responsible for fundamental operations like moving, resizing, and rotating elements. Currently, these operations are hardcoded, making it challenging to extend functionalities without diving deep into the code. ### What's Changing: Introducing `ElementTransformManager` to streamline the handling of basic transformations (move, resize, rotate) while allowing the business logic to dictate when these actions occur. Providing two ways to extend the transformations behaviour: - Extends inside element view definition: Elements can decide how to handle move/resize events, such as enforcing size constraints. - Extension mechanism provided by this manager: Adjust or completely override default drag behaviors, like snapping elements into alignment. ### Code Examples: Delegate element movement to TransformManager: ```typescript class DefaultTool { override dragStart(event) { if(this.dragType === DragType.ContentMoving) { const transformManager = this.std.get(TransformManagerIdentifier); transformManager.startDrag({ selectedElements, event }); } } } ``` Enforce minimum width inside view definition: ```typescript class EdgelessNoteBlock extends GfxBlockComponent { onResizeDelta({ dw, dh }) { const bound = this.model.elementBound; bound.w = Math.min(MAX_WIDTH, bound.w + dw); bound.h = Math.min(MAX_HEIGHT, bound.h + dh); this.model.xywh = bound.serialize(); } } ``` Use extension to implement element snapping: ```typescript import { TransformerExtension } from '@blocksuite/std/gfx'; // Just extends the TransformerExtension class SnapManager extends TransformerExtension { static override key = 'snap-manager'; onDragInitialize() { return { onDragMove(context) { const { dx, dy } = this.getAlignmentMoveDistance(context.elements); context.dx = dx; context.dy = dy; } } } } ``` ### Others The migration will be divided into several PRs. This PR mostly focus on refactoring elements movement part of `default-tool`. - Delegate elements movement to `TransformManager` - Rewrite the default tool extension into `TransformManager` extension - Add drag handler interface to gfx view (both `GfxBlockComponent` and `GfxElementModelView`) to allow element to define how it gonna react on drag --- .../src/edgeless/edgeless-builtin-spec.ts | 24 +- .../element-transform/connector-filter.ts | 39 +++ .../frame-highlight-manager.ts | 87 +++++ .../mind-map-drag.ts} | 109 +++--- .../element-transform/snap-manager.ts | 64 ++++ .../utils}/drag-utils.ts | 0 .../utils}/indicator-overlay.ts | 0 .../edgeless/gfx-tool/default-tool-ext/ext.ts | 49 --- .../src/edgeless/gfx-tool/default-tool.ts | 329 ++---------------- .../src/edgeless/utils/snap-manager.ts | 6 +- .../blocks/block-surface/src/surface-spec.ts | 5 +- .../block-surface/src/view/connector.ts | 27 ++ .../framework/block-std/src/gfx/controller.ts | 9 + .../src/gfx/element-transform/drag.ts | 73 ++++ .../extension/canvas-event-handler.ts} | 14 +- .../element-transform/transform-manager.ts | 304 ++++++++++++++++ .../gfx/element-transform/view-transform.ts | 43 +++ .../framework/block-std/src/gfx/index.ts | 19 + .../block-std/src/gfx/view/view-manager.ts | 31 +- .../framework/block-std/src/gfx/view/view.ts | 30 +- .../src/view/element/gfx-block-component.ts | 47 ++- .../e2e/fragments/frame-panel.spec.ts | 5 +- tests/kit/src/utils/editor.ts | 3 +- 23 files changed, 864 insertions(+), 453 deletions(-) create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/element-transform/connector-filter.ts create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/element-transform/frame-highlight-manager.ts rename blocksuite/affine/blocks/block-root/src/edgeless/{gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts => element-transform/mind-map-drag.ts} (81%) create mode 100644 blocksuite/affine/blocks/block-root/src/edgeless/element-transform/snap-manager.ts rename blocksuite/affine/blocks/block-root/src/edgeless/{gfx-tool/default-tool-ext/mind-map-ext => element-transform/utils}/drag-utils.ts (100%) rename blocksuite/affine/blocks/block-root/src/edgeless/{gfx-tool/default-tool-ext/mind-map-ext => element-transform/utils}/indicator-overlay.ts (100%) create mode 100644 blocksuite/affine/blocks/block-surface/src/view/connector.ts create mode 100644 blocksuite/framework/block-std/src/gfx/element-transform/drag.ts rename blocksuite/{affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/event-ext.ts => framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts} (83%) create mode 100644 blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts create mode 100644 blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts index 8c550e5e24..832c4612e4 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/edgeless-builtin-spec.ts @@ -1,14 +1,22 @@ import { PresentTool } from '@blocksuite/affine-block-frame'; import { ConnectionOverlay } from '@blocksuite/affine-block-surface'; import { TextTool } from '@blocksuite/affine-gfx-text'; +import { + CanvasEventHandler, + ElementTransformManager, +} from '@blocksuite/block-std/gfx'; import type { ExtensionType } from '@blocksuite/store'; import { EdgelessElementToolbarExtension } from './configs/toolbar'; import { EdgelessRootBlockSpec } from './edgeless-root-spec.js'; +import { ConnectorFilter } from './element-transform/connector-filter.js'; +import { FrameHighlightManager } from './element-transform/frame-highlight-manager.js'; +import { MindMapDragExtension } from './element-transform/mind-map-drag.js'; +import { SnapExtension } from './element-transform/snap-manager.js'; +import { MindMapIndicatorOverlay } from './element-transform/utils/indicator-overlay.js'; import { BrushTool } from './gfx-tool/brush-tool.js'; import { ConnectorTool } from './gfx-tool/connector-tool.js'; import { DefaultTool } from './gfx-tool/default-tool.js'; -import { MindMapIndicatorOverlay } from './gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.js'; import { EmptyTool } from './gfx-tool/empty-tool.js'; import { EraserTool } from './gfx-tool/eraser-tool.js'; import { FrameTool } from './gfx-tool/frame-tool.js'; @@ -18,7 +26,7 @@ import { PanTool } from './gfx-tool/pan-tool.js'; import { ShapeTool } from './gfx-tool/shape-tool.js'; import { TemplateTool } from './gfx-tool/template-tool.js'; import { EditPropsMiddlewareBuilder } from './middlewares/base.js'; -import { SnapManager } from './utils/snap-manager.js'; +import { SnapOverlay } from './utils/snap-manager.js'; export const EdgelessToolExtension: ExtensionType[] = [ DefaultTool, @@ -36,10 +44,19 @@ export const EdgelessToolExtension: ExtensionType[] = [ PresentTool, ]; +export const EdgelessEditExtensions: ExtensionType[] = [ + ElementTransformManager, + ConnectorFilter, + SnapExtension, + CanvasEventHandler, + MindMapDragExtension, + FrameHighlightManager, +]; + export const EdgelessBuiltInManager: ExtensionType[] = [ ConnectionOverlay, MindMapIndicatorOverlay, - SnapManager, + SnapOverlay, EditPropsMiddlewareBuilder, EdgelessElementToolbarExtension, ].flat(); @@ -48,4 +65,5 @@ export const EdgelessBuiltInSpecs: ExtensionType[] = [ EdgelessRootBlockSpec, EdgelessToolExtension, EdgelessBuiltInManager, + EdgelessEditExtensions, ].flat(); diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/connector-filter.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/connector-filter.ts new file mode 100644 index 0000000000..97a8930f92 --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/connector-filter.ts @@ -0,0 +1,39 @@ +import { ConnectorElementModel } from '@blocksuite/affine-model'; +import { + type DragExtensionInitializeContext, + TransformExtension, +} from '@blocksuite/block-std/gfx'; + +export class ConnectorFilter extends TransformExtension { + static override key = 'connector-filter'; + override onDragInitialize(context: DragExtensionInitializeContext) { + let hasConnectorFlag = false; + + const elementSet = new Set(context.elements.map(elem => elem.id)); + const elements = context.elements.filter(elem => { + if (elem instanceof ConnectorElementModel) { + const sourceElemNotFound = + elem.source.id && !elementSet.has(elem.source.id); + const targetElemNotFound = + elem.target.id && !elementSet.has(elem.target.id); + + // If either source or target element is not found, then remove the connector + if (sourceElemNotFound || targetElemNotFound) { + return false; + } + + hasConnectorFlag = true; + return true; + } + + return true; + }); + + if (hasConnectorFlag) { + // connector needs to be updated first + elements.sort((a, _) => (a instanceof ConnectorElementModel ? -1 : 1)); + } + + return {}; + } +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/frame-highlight-manager.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/frame-highlight-manager.ts new file mode 100644 index 0000000000..28f8322aff --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/frame-highlight-manager.ts @@ -0,0 +1,87 @@ +import { + type EdgelessFrameManager, + type FrameOverlay, + isFrameBlock, +} from '@blocksuite/affine-block-frame'; +import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; +import { + type FrameBlockModel, + MindmapElementModel, +} from '@blocksuite/affine-model'; +import { + type DragExtensionInitializeContext, + type ExtensionDragEndContext, + type ExtensionDragMoveContext, + type ExtensionDragStartContext, + getTopElements, + GfxExtensionIdentifier, + TransformExtension, +} from '@blocksuite/block-std/gfx'; + +export class FrameHighlightManager extends TransformExtension { + static override key = 'frame-highlight-manager'; + + get frameMgr() { + return this.std.getOptional( + GfxExtensionIdentifier('frame-manager') + ) as EdgelessFrameManager; + } + + get frameHighlightOverlay() { + return this.std.getOptional(OverlayIdentifier('frame')) as FrameOverlay; + } + + override onDragInitialize(_: DragExtensionInitializeContext): { + onDragStart?: (context: ExtensionDragStartContext) => void; + onDragMove?: (context: ExtensionDragMoveContext) => void; + onDragEnd?: (context: ExtensionDragEndContext) => void; + clear?: () => void; + } { + if (!this.frameMgr || !this.frameHighlightOverlay) { + return {}; + } + + let hoveredFrame: FrameBlockModel | null = null; + const { frameMgr, frameHighlightOverlay } = this; + let draggedFrames: FrameBlockModel[] = []; + + return { + onDragStart(context) { + draggedFrames = context.elements + .map(elem => elem.model) + .filter(model => isFrameBlock(model)); + }, + onDragMove(context) { + const { dragLastPos } = context; + + hoveredFrame = frameMgr.getFrameFromPoint( + [dragLastPos.x, dragLastPos.y], + draggedFrames + ); + + if (hoveredFrame && !hoveredFrame.isLocked()) { + frameHighlightOverlay.highlight(hoveredFrame); + } else { + frameHighlightOverlay.clear(); + } + }, + onDragEnd(context) { + const topElements = getTopElements( + context.elements.map(elem => + elem.model.group instanceof MindmapElementModel + ? elem.model.group + : elem.model + ) + ); + + if (hoveredFrame) { + frameMgr.addElementsToFrame(hoveredFrame, topElements); + } else { + topElements.forEach(elem => frameMgr.removeFromParentFrame(elem)); + } + + frameHighlightOverlay.clear(); + }, + }; + } +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/mind-map-drag.ts similarity index 81% rename from blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts rename to blocksuite/affine/blocks/block-root/src/edgeless/element-transform/mind-map-drag.ts index 927feaf6e6..5378701e5b 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/mind-map-ext.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/mind-map-drag.ts @@ -11,39 +11,41 @@ import { MindmapElementModel, type MindmapNode, } from '@blocksuite/affine-model'; -import type { PointerEventState } from '@blocksuite/block-std'; import { + type DragExtensionInitializeContext, + type ExtensionDragEndContext, + type ExtensionDragMoveContext, + type ExtensionDragStartContext, type GfxModel, type GfxPrimitiveElementModel, isGfxGroupCompatibleModel, + TransformExtension, } from '@blocksuite/block-std/gfx'; import type { Bound, IVec } from '@blocksuite/global/gfx'; -import { isSingleMindMapNode } from '../../../utils/mindmap.js'; -import { isMindmapNode } from '../../../utils/query.js'; -import { DefaultModeDragType, DefaultToolExt, type DragState } from '../ext.js'; -import { calculateResponseArea } from './drag-utils.js'; -import type { MindMapIndicatorOverlay } from './indicator-overlay.js'; +import { isSingleMindMapNode } from '../utils/mindmap'; +import { isMindmapNode } from '../utils/query'; +import { calculateResponseArea } from './utils/drag-utils'; +import type { MindMapIndicatorOverlay } from './utils/indicator-overlay'; type DragMindMapCtx = { mindmap: MindmapElementModel; node: MindmapNode; - clear: () => void; /** * Whether the dragged node is the root node of the mind map */ isRoot: boolean; originalMindMapBound: Bound; - startPoint: PointerEventState; }; -export class MindMapExt extends DefaultToolExt { +export class MindMapDragExtension extends TransformExtension { + static override key = 'mind-map-drag'; + /** + * The response area of the mind map is calculated in real time. + * It only needs to be calculated once when the mind map is dragged. + */ private readonly _responseAreaUpdated = new Set(); - override supportedDragTypes: DefaultModeDragType[] = [ - DefaultModeDragType.ContentMoving, - ]; - private get _indicatorOverlay() { return this.std.getOptional( OverlayIdentifier('mindmap-indicator') @@ -62,9 +64,8 @@ export class MindMapExt extends DefaultToolExt { * @returns */ private _createManipulationHandlers(dragMindMapCtx: DragMindMapCtx): { - dragStart?: (evt: PointerEventState) => void; - dragMove?: (evt: PointerEventState) => void; - dragEnd?: (evt: PointerEventState) => void; + onDragMove?: (context: ExtensionDragMoveContext) => void; + onDragEnd?: (context: ExtensionDragEndContext) => void; } { let hoveredCtx: { mindmap: MindmapElementModel | null; @@ -75,8 +76,8 @@ export class MindMapExt extends DefaultToolExt { } | null = null; return { - dragMove: (_: PointerEventState) => { - const [x, y] = this.defaultTool.dragLastPos; + onDragMove: (context: ExtensionDragMoveContext) => { + const { x, y } = context.dragLastPos; const hoveredMindMap = this._getHoveredMindMap([x, y], dragMindMapCtx); const indicator = this._indicatorOverlay; @@ -166,18 +167,15 @@ export class MindMapExt extends DefaultToolExt { } } }, - dragEnd: (e: PointerEventState) => { + onDragEnd: (dragEndContext: ExtensionDragEndContext) => { if (hoveredCtx?.merge) { hoveredCtx.merge(); } else { hoveredCtx?.abort?.(); if (hoveredCtx?.detach) { - const [startX, startY] = this.gfx.viewport.toModelCoord( - dragMindMapCtx.startPoint.x, - dragMindMapCtx.startPoint.y - ); - const [endX, endY] = this.gfx.viewport.toModelCoord(e.x, e.y); + const { x: startX, y: startY } = dragEndContext.dragStartPos; + const { x: endX, y: endY } = dragEndContext.dragLastPos; dragMindMapCtx.node.element.xywh = dragMindMapCtx.node.element.elementBound @@ -204,7 +202,6 @@ export class MindMapExt extends DefaultToolExt { } hoveredCtx = null; - dragMindMapCtx.clear(); this._responseAreaUpdated.clear(); }, }; @@ -213,24 +210,21 @@ export class MindMapExt extends DefaultToolExt { /** * Create handlers that can translate entire mind map */ - private _createTranslationHandlers( - _: DragState, - ctx: { - mindmaps: Set; - nodes: Set; - } - ): { - dragStart?: (evt: PointerEventState) => void; - dragMove?: (evt: PointerEventState) => void; - dragEnd?: (evt: PointerEventState) => void; + private _createTranslationHandlers(ctx: { + mindmaps: Set; + nodes: Set; + }): { + onDragStart?: (context: ExtensionDragStartContext) => void; + onDragMove?: (context: ExtensionDragMoveContext) => void; + onDragEnd?: (context: ExtensionDragEndContext) => void; } { return { - dragStart: (_: PointerEventState) => { + onDragStart: () => { ctx.nodes.forEach(node => { node.stash('xywh'); }); }, - dragEnd: (_: PointerEventState) => { + onDragEnd: () => { ctx.mindmaps.forEach(mindmap => { mindmap.layout(); }); @@ -317,7 +311,7 @@ export class MindMapExt extends DefaultToolExt { private _setupDragNodeImage( mindmapNode: MindmapNode, - event: PointerEventState + pos: { x: number; y: number } ) { const surfaceBlock = this.gfx.surfaceComponent as SurfaceBlockComponent; const renderer = surfaceBlock?.renderer; @@ -329,7 +323,6 @@ export class MindMapExt extends DefaultToolExt { const nodeBound = mindmapNode.element.elementBound; - const pos = this.gfx.viewport.toModelCoord(event.x, event.y); const canvas = renderer.getCanvasByBound( mindmapNode.element.elementBound, [mindmapNode.element], @@ -338,7 +331,7 @@ export class MindMapExt extends DefaultToolExt { false ); - indicatorOverlay.dragNodePos = [nodeBound.x - pos[0], nodeBound.y - pos[1]]; + indicatorOverlay.dragNodePos = [nodeBound.x - pos.x, nodeBound.y - pos.y]; indicatorOverlay.dragNodeImage = canvas; return () => { @@ -385,14 +378,10 @@ export class MindMapExt extends DefaultToolExt { }; } - override initDrag(dragState: DragState) { - if (dragState.dragType !== DefaultModeDragType.ContentMoving) { - return {}; - } - - if (isSingleMindMapNode(dragState.movedElements)) { - const mindmap = dragState.movedElements[0].group as MindmapElementModel; - const mindmapNode = mindmap.getNode(dragState.movedElements[0].id)!; + override onDragInitialize(context: DragExtensionInitializeContext) { + if (isSingleMindMapNode(context.elements)) { + const mindmap = context.elements[0].group as MindmapElementModel; + const mindmapNode = mindmap.getNode(context.elements[0].id)!; const mindmapBound = mindmap.elementBound; const isRoot = mindmapNode === mindmap.tree; @@ -405,34 +394,36 @@ export class MindMapExt extends DefaultToolExt { const clearDragStatus = isRoot ? mindmap.stashTree(mindmapNode) - : this._setupDragNodeImage(mindmapNode, dragState.event); + : this._setupDragNodeImage(mindmapNode, context.dragStartPos); const clearOpacity = this._updateNodeOpacity(mindmap, mindmapNode); if (!isRoot) { - dragState.movedElements.splice(0, 1); + context.elements.splice(0, 1); } const mindMapDragCtx: DragMindMapCtx = { mindmap, node: mindmapNode, isRoot, - clear: () => { + originalMindMapBound: mindmapBound, + }; + + return { + ...this._createManipulationHandlers(mindMapDragCtx), + clear() { clearOpacity(); clearDragStatus?.(); if (!isRoot) { - dragState.movedElements.push(mindmapNode.element); + context.elements.push(mindmapNode.element); } }, - originalMindMapBound: mindmapBound, - startPoint: dragState.event, }; - - return this._createManipulationHandlers(mindMapDragCtx); } const mindmapNodes = new Set(); const mindmaps = new Set(); - dragState.movedElements.forEach(el => { + + context.elements.forEach(el => { if (isMindmapNode(el)) { const mindmap = el.group instanceof MindmapElementModel @@ -452,8 +443,8 @@ export class MindMapExt extends DefaultToolExt { }); if (mindmapNodes.size > 1) { - mindmapNodes.forEach(node => dragState.movedElements.push(node)); - return this._createTranslationHandlers(dragState, { + mindmapNodes.forEach(node => context.elements.push(node)); + return this._createTranslationHandlers({ mindmaps, nodes: mindmapNodes, }); diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/snap-manager.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/snap-manager.ts new file mode 100644 index 0000000000..14d7509780 --- /dev/null +++ b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/snap-manager.ts @@ -0,0 +1,64 @@ +import { OverlayIdentifier } from '@blocksuite/affine-block-surface'; +import { MindmapElementModel } from '@blocksuite/affine-model'; +import { + type DragExtensionInitializeContext, + type ExtensionDragMoveContext, + type GfxModel, + TransformExtension, +} from '@blocksuite/block-std/gfx'; +import type { Bound } from '@blocksuite/global/gfx'; + +import type { SnapOverlay } from '../utils/snap-manager'; + +export class SnapExtension extends TransformExtension { + static override key = 'snap-manager'; + + get snapOverlay() { + return this.std.getOptional( + OverlayIdentifier('snap-manager') + ) as SnapOverlay; + } + + override onDragInitialize(initContext: DragExtensionInitializeContext) { + const snapOverlay = this.snapOverlay; + + if (!snapOverlay) { + return {}; + } + + let alignBound: Bound; + + return { + onDragStart() { + alignBound = snapOverlay.setMovingElements( + initContext.elements, + initContext.elements.reduce((pre, elem) => { + if (elem.group instanceof MindmapElementModel) { + pre.push(elem.group); + } + + return pre; + }, [] as GfxModel[]) + ); + }, + onDragMove(context: ExtensionDragMoveContext) { + if ( + context.elements.length === 0 || + alignBound.w === 0 || + alignBound.h === 0 + ) { + return; + } + + const currentBound = alignBound.moveDelta(context.dx, context.dy); + const alignRst = snapOverlay.align(currentBound); + + context.dx = alignRst.dx + context.dx; + context.dy = alignRst.dy + context.dy; + }, + onDragEnd() { + snapOverlay.clear(); + }, + }; + } +} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/utils/drag-utils.ts similarity index 100% rename from blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/drag-utils.ts rename to blocksuite/affine/blocks/block-root/src/edgeless/element-transform/utils/drag-utils.ts diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts b/blocksuite/affine/blocks/block-root/src/edgeless/element-transform/utils/indicator-overlay.ts similarity index 100% rename from blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.ts rename to blocksuite/affine/blocks/block-root/src/edgeless/element-transform/utils/indicator-overlay.ts diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/ext.ts b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/ext.ts index 707ce18e43..c9766d87a3 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/ext.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/ext.ts @@ -1,11 +1,4 @@ -import type { PointerEventState } from '@blocksuite/block-std'; -import type { GfxModel } from '@blocksuite/block-std/gfx'; - -import type { DefaultTool } from '../default-tool.js'; - export enum DefaultModeDragType { - /** press alt/option key to clone selected */ - AltCloning = 'alt-cloning', /** Moving connector label */ ConnectorLabelMoving = 'connector-label-moving', /** Moving selected contents */ @@ -19,45 +12,3 @@ export enum DefaultModeDragType { /** Expanding the dragging area, select the content covered inside */ Selecting = 'selecting', } - -export type DragState = { - movedElements: GfxModel[]; - dragType: DefaultModeDragType; - event: PointerEventState; -}; - -export class DefaultToolExt { - readonly supportedDragTypes: DefaultModeDragType[] = []; - - get gfx() { - return this.defaultTool.gfx; - } - - get std() { - return this.defaultTool.std; - } - - constructor(protected defaultTool: DefaultTool) {} - - click(_evt: PointerEventState) {} - - dblClick(_evt: PointerEventState) {} - - initDrag(_: DragState): { - dragStart?: (evt: PointerEventState) => void; - dragMove?: (evt: PointerEventState) => void; - dragEnd?: (evt: PointerEventState) => void; - } { - return {}; - } - - mounted() {} - - pointerDown(_evt: PointerEventState) {} - - pointerMove(_evt: PointerEventState) {} - - pointerUp(_evt: PointerEventState) {} - - unmounted() {} -} diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts index 4789a52ebc..e06ef13b48 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool.ts @@ -1,6 +1,5 @@ import { insertEdgelessTextCommand } from '@blocksuite/affine-block-edgeless-text'; import { - EdgelessFrameManagerIdentifier, type FrameOverlay, isFrameBlock, } from '@blocksuite/affine-block-frame'; @@ -12,7 +11,6 @@ import { import { addText, mountTextElementEditor } from '@blocksuite/affine-gfx-text'; import type { EdgelessTextBlockModel, - FrameBlockModel, NoteBlockModel, } from '@blocksuite/affine-model'; import { @@ -35,11 +33,10 @@ import type { PointerEventState } from '@blocksuite/block-std'; import { BaseTool, getTopElements, - type GfxBlockElementModel, type GfxModel, - type GfxPrimitiveElementModel, isGfxGroupCompatibleModel, type PointTestOptions, + TransformManagerIdentifier, } from '@blocksuite/block-std/gfx'; import { DisposableGroup } from '@blocksuite/global/disposable'; import type { IVec } from '@blocksuite/global/gfx'; @@ -53,25 +50,19 @@ import type { EdgelessRootBlockComponent } from '../edgeless-root-block.js'; import { prepareCloneData } from '../utils/clone-utils.js'; import { calPanDelta } from '../utils/panning-utils.js'; import { isCanvasElement, isEdgelessTextBlock } from '../utils/query.js'; -import type { SnapManager } from '../utils/snap-manager.js'; import { mountConnectorLabelEditor, mountFrameTitleEditor, mountGroupTitleEditor, mountShapeTextEditor, } from '../utils/text.js'; -import { CanvasElementEventExt } from './default-tool-ext/event-ext.js'; -import type { DefaultToolExt } from './default-tool-ext/ext.js'; import { DefaultModeDragType } from './default-tool-ext/ext.js'; -import { MindMapExt } from './default-tool-ext/mind-map-ext/mind-map-ext.js'; export class DefaultTool extends BaseTool { static override toolName: string = 'default'; private _accumulateDelta: IVec = [0, 0]; - private _alignBound = new Bound(); - private _autoPanTimer: number | null = null; private readonly _clearDisposable = () => { @@ -84,42 +75,19 @@ export class DefaultTool extends BaseTool { private readonly _clearSelectingState = () => { this._stopAutoPanning(); this._clearDisposable(); - - this._wheeling = false; }; private _disposables: DisposableGroup | null = null; - private _extHandlers: { - dragStart?: (evt: PointerEventState) => void; - dragMove?: (evt: PointerEventState) => void; - dragEnd?: (evt: PointerEventState) => void; - }[] = []; - - private _exts: DefaultToolExt[] = []; - - private _hoveredFrame: FrameBlockModel | null = null; - // Do not select the text, when click again after activating the note. private _isDoubleClickedOnMask = false; - private _lock = false; - private readonly _panViewport = (delta: IVec) => { this._accumulateDelta[0] += delta[0]; this._accumulateDelta[1] += delta[1]; this.gfx.viewport.applyDeltaCenter(delta[0], delta[1]); }; - private readonly _pendingUpdates = new Map< - GfxBlockElementModel | GfxPrimitiveElementModel, - Partial - >(); - - private _rafId: number | null = null; - - private _selectedBounds: Bound[] = []; - // For moving the connector label private _selectedConnector: ConnectorElementModel | null = null; @@ -219,8 +187,6 @@ export class DefaultTool extends BaseTool { }); }; - private _wheeling = false; - dragType = DefaultModeDragType.None; enableHover = true; @@ -231,16 +197,6 @@ export class DefaultTool extends BaseTool { return (block as EdgelessRootBlockComponent) ?? null; } - private get _frameMgr() { - return this.std.get(EdgelessFrameManagerIdentifier); - } - - private get _supportedExts() { - return this._exts.filter(ext => - ext.supportedDragTypes.includes(this.dragType) - ); - } - /** * Get the end position of the dragging area in the model coordinate */ @@ -263,12 +219,12 @@ export class DefaultTool extends BaseTool { return this.gfx.selection; } - private get frameOverlay() { - return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; + get elementTransformMgr() { + return this.std.getOptional(TransformManagerIdentifier); } - get snapOverlay() { - return this.std.get(OverlayIdentifier('snap-manager')) as SnapManager; + private get frameOverlay() { + return this.std.get(OverlayIdentifier('frame')) as FrameOverlay; } private _addEmptyParagraphBlock( @@ -285,8 +241,6 @@ export class DefaultTool extends BaseTool { } private async _cloneContent() { - this._lock = true; - if (!this._edgeless) return; const clipboardController = this._edgeless?.clipboardController; @@ -374,91 +328,6 @@ export class DefaultTool extends BaseTool { } } - private _filterConnectedConnector() { - this._toBeMoved = this._toBeMoved.filter(ele => { - // eslint-disable-next-line sonarjs/no-collapsible-if - if ( - ele instanceof ConnectorElementModel && - ele.source?.id && - ele.target?.id - ) { - if ( - this._toBeMoved.some(e => e.id === ele.source.id) && - this._toBeMoved.some(e => e.id === ele.target.id) - ) { - return false; - } - } - return true; - }); - } - - private _isDraggable(element: GfxModel) { - return !( - element instanceof ConnectorElementModel && - !ConnectorUtils.isConnectorAndBindingsAllSelected( - element, - this._toBeMoved - ) - ); - } - - private _moveContent( - [dx, dy]: IVec, - alignBound: Bound, - shifted?: boolean, - shouldClone?: boolean - ) { - alignBound.x += dx; - alignBound.y += dy; - - const alignRst = this.snapOverlay.align(alignBound); - const delta = [dx + alignRst.dx, dy + alignRst.dy]; - - if (shifted) { - const angle = Math.abs(Math.atan2(delta[1], delta[0])); - const direction = - angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'x' : 'y'; - delta[direction === 'x' ? 1 : 0] = 0; - } - - this._toBeMoved.forEach((element, index) => { - const isGraphicElement = isCanvasElement(element); - - if (isGraphicElement && !this._isDraggable(element)) return; - - let bound = this._selectedBounds[index]; - if (shouldClone) bound = bound.clone(); - - bound.x += delta[0]; - bound.y += delta[1]; - - if (isGraphicElement) { - if (!this._lock) { - this._lock = true; - this.doc.captureSync(); - } - - if (element instanceof ConnectorElementModel) { - element.moveTo(bound); - } - } - - this._scheduleUpdate(element, { - xywh: bound.serialize(), - }); - }); - - this._hoveredFrame = this._frameMgr.getFrameFromPoint( - this.dragLastPos, - this._toBeMoved.filter(ele => isFrameBlock(ele)) - ); - - this._hoveredFrame && !this._hoveredFrame.isLocked() - ? this.frameOverlay.highlight(this._hoveredFrame) - : this.frameOverlay.clear(); - } - private _moveLabel(delta: IVec) { const connector = this._selectedConnector; let bounds = this._selectedConnectorLabelBounds; @@ -535,62 +404,15 @@ export class DefaultTool extends BaseTool { return tryGetLockedAncestor(result); } - private _scheduleUpdate( - element: GfxBlockElementModel | GfxPrimitiveElementModel, - updates: Partial - ) { - this._pendingUpdates.set(element, updates); - - if (this._rafId !== null) return; - - this._rafId = requestAnimationFrame(() => { - this._pendingUpdates.forEach((updates, element) => { - this.gfx.updateElement(element, updates); - }); - this._pendingUpdates.clear(); - this._rafId = null; - }); - } - private initializeDragState( dragType: DefaultModeDragType, event: PointerEventState ) { this.dragType = dragType; - const mindmaps: MindmapElementModel[] = this._toBeMoved.reduce( - (pre, elem) => { - if ( - elem.group instanceof MindmapElementModel && - !pre.includes(elem.group) - ) { - pre.push(elem.group); - } - - return pre; - }, - [] as MindmapElementModel[] - ); - - this._alignBound = this.snapOverlay.setMovingElements(this._toBeMoved, [ - ...mindmaps, - ...mindmaps.flatMap(m => m.childElements), - ]); - this._clearDisposable(); this._disposables = new DisposableGroup(); - const ctx = { - movedElements: this._toBeMoved, - dragType, - event, - }; - - this._extHandlers = this._supportedExts.map(ext => ext.initDrag(ctx)); - this._selectedBounds = this._toBeMoved.map(element => - Bound.deserialize(element.xywh) - ); - // If the drag type is selecting, set up the dragging area disposable group // If the viewport updates when dragging, should update the dragging area and selection if (this.dragType === DefaultModeDragType.Selecting) { @@ -609,36 +431,16 @@ export class DefaultTool extends BaseTool { } if (this.dragType === DefaultModeDragType.ContentMoving) { - this._disposables.add( - this.gfx.viewport.viewportMoved.subscribe(delta => { - if ( - this.dragType === DefaultModeDragType.ContentMoving && - this.controller.dragging$.peek() && - !this._autoPanTimer - ) { - if ( - this._toBeMoved.every(ele => { - return !this._isDraggable(ele); - }) - ) { - return; - } - - if (!this._wheeling) { - this._wheeling = true; - this._selectedBounds = this._toBeMoved.map(element => - Bound.deserialize(element.xywh) - ); - } - - this._alignBound = this.snapOverlay.setMovingElements( - this._toBeMoved - ); - - this._moveContent(delta, this._alignBound); - } - }) - ); + if (this.elementTransformMgr) { + this.doc.captureSync(); + this.elementTransformMgr.initializeDrag({ + movingElements: this._toBeMoved, + event: event.raw, + onDragEnd: () => { + this.doc.captureSync(); + }, + }); + } return; } } @@ -742,7 +544,7 @@ export class DefaultTool extends BaseTool { } this._isDoubleClickedOnMask = false; - this._supportedExts.forEach(ext => ext.click?.(e)); + this.elementTransformMgr?.dispatch('click', e); } override deactivate() { @@ -819,7 +621,7 @@ export class DefaultTool extends BaseTool { } } - this._supportedExts.forEach(ext => ext.click?.(e)); + this.elementTransformMgr?.dispatch('dblclick', e); if ( e.raw.target && @@ -832,48 +634,9 @@ export class DefaultTool extends BaseTool { } } - override dragEnd(e: PointerEventState) { - this._extHandlers.forEach(handler => handler.dragEnd?.(e)); - - this._toBeMoved.forEach(el => { - this.doc.transact(() => { - el.pop('xywh'); - }); - - if (el instanceof ConnectorElementModel) { - el.pop('labelXYWH'); - } - }); - - { - const frameManager = this._frameMgr; - const toBeMovedTopElements = getTopElements( - this._toBeMoved.map(el => - el.group instanceof MindmapElementModel ? el.group : el - ) - ); - if (this._hoveredFrame) { - frameManager.addElementsToFrame( - this._hoveredFrame, - toBeMovedTopElements - ); - } else { - // only apply to root nodes of trees - toBeMovedTopElements.forEach(element => - frameManager.removeFromParentFrame(element) - ); - } - } - - if (this._lock) { - this.doc.captureSync(); - this._lock = false; - } - + override dragEnd() { if (this.edgelessSelectionManager.editing) return; - this._selectedBounds = []; - this.snapOverlay.clear(); this.frameOverlay.clear(); this._toBeMoved = []; this._selectedConnector = null; @@ -897,28 +660,7 @@ export class DefaultTool extends BaseTool { } break; } - case DefaultModeDragType.AltCloning: case DefaultModeDragType.ContentMoving: { - if ( - this._toBeMoved.length && - this._toBeMoved.every(ele => { - return !this._isDraggable(ele); - }) - ) { - return; - } - - if (this._wheeling) { - this._wheeling = false; - } - - const dx = this.dragLastPos[0] - this.dragStartPos[0]; - const dy = this.dragLastPos[1] - this.dragStartPos[1]; - const alignBound = this._alignBound.clone(); - const shifted = e.keys.shift || this.gfx.keyboard.shiftKey$.peek(); - - this._moveContent([dx, dy], alignBound, shifted, true); - this._extHandlers.forEach(handler => handler.dragMove?.(e)); break; } case DefaultModeDragType.ConnectorLabelMoving: { @@ -956,30 +698,12 @@ export class DefaultTool extends BaseTool { this._toBeMoved = Array.from(toBeMoved); // If alt key is pressed and content is moving, clone the content - if (e.keys.alt && dragType === DefaultModeDragType.ContentMoving) { - dragType = DefaultModeDragType.AltCloning; + if (dragType === DefaultModeDragType.ContentMoving && e.keys.alt) { await this._cloneContent(); } - this._filterConnectedConnector(); - - // Connector needs to be updated first - this._toBeMoved.sort((a, _) => - a instanceof ConnectorElementModel ? -1 : 1 - ); // Set up drag state this.initializeDragState(dragType, e); - - // stash the state - this._toBeMoved.forEach(ele => { - ele.stash('xywh'); - - if (ele instanceof ConnectorElementModel) { - ele.stash('labelXYWH'); - } - }); - - this._extHandlers.forEach(handler => handler.dragStart?.(e)); } override mounted() { @@ -1003,15 +727,10 @@ export class DefaultTool extends BaseTool { } }) ); - - this._exts = [MindMapExt, CanvasElementEventExt].map( - constructor => new constructor(this) - ); - this._exts.forEach(ext => ext.mounted()); } override pointerDown(e: PointerEventState): void { - this._supportedExts.forEach(ext => ext.pointerDown(e)); + this.elementTransformMgr?.dispatch('pointerdown', e); } override pointerMove(e: PointerEventState) { @@ -1030,20 +749,18 @@ export class DefaultTool extends BaseTool { this.frameOverlay.clear(); } - this._supportedExts.forEach(ext => ext.pointerMove(e)); + this.elementTransformMgr?.dispatch('pointermove', e); } override pointerUp(e: PointerEventState) { - this._supportedExts.forEach(ext => ext.pointerUp(e)); + this.elementTransformMgr?.dispatch('pointerup', e); } override tripleClick() { if (this._isDoubleClickedOnMask) return; } - override unmounted(): void { - this._exts.forEach(ext => ext.unmounted()); - } + override unmounted(): void {} } declare module '@blocksuite/block-std/gfx' { diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/utils/snap-manager.ts b/blocksuite/affine/blocks/block-root/src/edgeless/utils/snap-manager.ts index bce7d411d9..205dc5c3fb 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/utils/snap-manager.ts +++ b/blocksuite/affine/blocks/block-root/src/edgeless/utils/snap-manager.ts @@ -36,7 +36,7 @@ const ALIGN_THRESHOLD = 8; const DISTRIBUTION_LINE_OFFSET = 1; const STROKE_WIDTH = 2; -export class SnapManager extends Overlay { +export class SnapOverlay extends Overlay { static override overlayName: string = 'snap-manager'; private _skippedElements: Set = new Set(); @@ -76,8 +76,6 @@ export class SnapManager extends Overlay { }; override clear() { - super.clear(); - this._referenceBounds = { vertical: [], horizontal: [], @@ -89,6 +87,8 @@ export class SnapManager extends Overlay { }; this._distributedAlignLines = []; this._skippedElements.clear(); + + super.clear(); } private _alignDistributeHorizontally( diff --git a/blocksuite/affine/blocks/block-surface/src/surface-spec.ts b/blocksuite/affine/blocks/block-surface/src/surface-spec.ts index 55f9953410..b086bc673e 100644 --- a/blocksuite/affine/blocks/block-surface/src/surface-spec.ts +++ b/blocksuite/affine/blocks/block-surface/src/surface-spec.ts @@ -12,17 +12,19 @@ import { } from './extensions'; import { ExportManagerExtension } from './extensions/export-manager/export-manager'; import { SurfaceBlockService } from './surface-service'; +import { ConnectorElementView } from './view/connector'; import { MindMapView } from './view/mindmap'; const CommonSurfaceBlockSpec: ExtensionType[] = [ FlavourExtension('affine:surface'), SurfaceBlockService, - MindMapView, EdgelessCRUDExtension, EdgelessLegacySlotExtension, ExportManagerExtension, ]; +const ElementModelViews = [MindMapView, ConnectorElementView]; + export const PageSurfaceBlockSpec: ExtensionType[] = [ ...CommonSurfaceBlockSpec, ...SurfaceBlockAdapterExtensions, @@ -32,5 +34,6 @@ export const PageSurfaceBlockSpec: ExtensionType[] = [ export const EdgelessSurfaceBlockSpec: ExtensionType[] = [ ...CommonSurfaceBlockSpec, ...EdgelessSurfaceBlockAdapterExtensions, + ...ElementModelViews, BlockViewExtension('affine:surface', literal`affine-surface`), ]; diff --git a/blocksuite/affine/blocks/block-surface/src/view/connector.ts b/blocksuite/affine/blocks/block-surface/src/view/connector.ts new file mode 100644 index 0000000000..a2e3c2ce24 --- /dev/null +++ b/blocksuite/affine/blocks/block-surface/src/view/connector.ts @@ -0,0 +1,27 @@ +import type { ConnectorElementModel } from '@blocksuite/affine-model'; +import { + type DragEndContext, + type DragMoveContext, + type DragStartContext, + GfxElementModelView, +} from '@blocksuite/block-std/gfx'; + +export class ConnectorElementView extends GfxElementModelView { + static override type = 'connector'; + + override onDragStart = (context: DragStartContext) => { + super.onDragStart(context); + this.model.stash('labelXYWH'); + }; + + override onDragEnd = (context: DragEndContext) => { + super.onDragEnd(context); + this.model.stash('labelXYWH'); + }; + + override onDragMove = (context: DragMoveContext) => { + const { dx, dy, currentBound } = context; + + this.model.moveTo(currentBound.moveDelta(dx, dy)); + }; +} diff --git a/blocksuite/framework/block-std/src/gfx/controller.ts b/blocksuite/framework/block-std/src/gfx/controller.ts index db1ab7f58a..a2209b1810 100644 --- a/blocksuite/framework/block-std/src/gfx/controller.ts +++ b/blocksuite/framework/block-std/src/gfx/controller.ts @@ -181,6 +181,15 @@ 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, diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/drag.ts b/blocksuite/framework/block-std/src/gfx/element-transform/drag.ts new file mode 100644 index 0000000000..7816864479 --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/element-transform/drag.ts @@ -0,0 +1,73 @@ +import type { Bound } from '@blocksuite/global/gfx'; + +import type { GfxBlockComponent } from '../../view'; +import type { GfxModel } from '../model/model'; +import type { GfxElementModelView } from '../view/view'; + +export type DragInitializationOption = { + movingElements: GfxModel[]; + event: PointerEvent | MouseEvent; + onDragEnd?: () => void; +}; + +export type DragExtensionInitializeContext = { + /** + * The elements that are being dragged. + * The extension can modify this array to add or remove dragging elements. + */ + elements: GfxModel[]; + + /** + * Prevent the default drag behavior. The following drag events will not be triggered. + */ + preventDefault: () => void; + + /** + * The start position of the drag in model space. + */ + dragStartPos: Readonly<{ + x: number; + y: number; + }>; +}; + +export type ExtensionBaseEvent = { + /** + * The elements that respond to the event. + */ + elements: { + view: GfxBlockComponent | GfxElementModelView; + originalBound: Bound; + model: GfxModel; + }[]; + + /** + * The mouse event + */ + event: PointerEvent; + + /** + * The start position of the drag in model space. + */ + dragStartPos: Readonly<{ + x: number; + y: number; + }>; + + /** + * The last position of the drag in model space. + */ + dragLastPos: Readonly<{ + x: number; + y: number; + }>; +}; + +export type ExtensionDragStartContext = ExtensionBaseEvent; + +export type ExtensionDragMoveContext = ExtensionBaseEvent & { + dx: number; + dy: number; +}; + +export type ExtensionDragEndContext = ExtensionDragMoveContext; diff --git a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/event-ext.ts b/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts similarity index 83% rename from blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/event-ext.ts rename to blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts index 256047108c..d943b02760 100644 --- a/blocksuite/affine/blocks/block-root/src/edgeless/gfx-tool/default-tool-ext/event-ext.ts +++ b/blocksuite/framework/block-std/src/gfx/element-transform/extension/canvas-event-handler.ts @@ -1,17 +1,15 @@ -import type { PointerEventState } from '@blocksuite/block-std'; -import type { GfxElementModelView } from '@blocksuite/block-std/gfx'; import { Bound } from '@blocksuite/global/gfx'; import last from 'lodash-es/last'; -import { DefaultModeDragType, DefaultToolExt } from './ext.js'; +import type { PointerEventState } from '../../../event'; +import type { GfxElementModelView } from '../../view/view'; +import { TransformExtension } from '../transform-manager'; + +export class CanvasEventHandler extends TransformExtension { + static override key = 'canvas-event-handler'; -export class CanvasElementEventExt extends DefaultToolExt { private _currentStackedElm: GfxElementModelView[] = []; - override supportedDragTypes: DefaultModeDragType[] = [ - DefaultModeDragType.None, - ]; - private _callInReverseOrder( callback: (view: GfxElementModelView) => void, arr = this._currentStackedElm diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts b/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts new file mode 100644 index 0000000000..540e91686d --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/element-transform/transform-manager.ts @@ -0,0 +1,304 @@ +import { + type Container, + createIdentifier, + type ServiceIdentifier, +} from '@blocksuite/global/di'; +import { DisposableGroup } from '@blocksuite/global/disposable'; +import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions'; +import { Bound, Point } from '@blocksuite/global/gfx'; +import { Extension } from '@blocksuite/store'; + +import type { PointerEventState } from '../../event/state/pointer.js'; +import { type GfxController } from '../controller.js'; +import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; +import { GfxControllerIdentifier } from '../identifiers.js'; +import { type SupportedEvent } from '../view/view.js'; +import type { + DragExtensionInitializeContext, + DragInitializationOption, + ExtensionDragEndContext, + ExtensionDragMoveContext, + ExtensionDragStartContext, +} from './drag.js'; + +type ExtensionPointerHandler = Exclude< + SupportedEvent, + 'pointerleave' | 'pointerenter' +>; + +export const TransformManagerIdentifier = GfxExtensionIdentifier( + 'element-transform-manager' +) as ServiceIdentifier; + +const CAMEL_CASE_MAP: { + [key in ExtensionPointerHandler]: keyof Pick< + TransformExtension, + 'click' | 'dblClick' | 'pointerDown' | 'pointerMove' | 'pointerUp' + >; +} = { + click: 'click', + dblclick: 'dblClick', + pointerdown: 'pointerDown', + pointermove: 'pointerMove', + pointerup: 'pointerUp', +}; + +export class ElementTransformManager extends GfxExtension { + static override key = 'element-transform-manager'; + + private readonly _disposable = new DisposableGroup(); + + override mounted(): void { + // + } + + override unmounted(): void { + this._disposable.dispose(); + } + + get transformExtensions() { + return this.std.provider.getAll(TransformExtensionIdentifier); + } + + get keyboard() { + return this.gfx.keyboard; + } + + private _safeExecute(fn: () => void, errorMessage: string) { + try { + fn(); + } catch (e) { + console.error(errorMessage, e); + } + } + + dispatch(eventName: ExtensionPointerHandler, evt: PointerEventState) { + const transformExtensions = this.transformExtensions; + + transformExtensions.forEach(ext => { + const handlerMethodName = CAMEL_CASE_MAP[eventName]; + + if (ext[handlerMethodName]) { + this._safeExecute(() => { + ext[handlerMethodName](evt); + }, `Error while executing extension \`${handlerMethodName}\` handler`); + } + }); + } + + initializeDrag(options: DragInitializationOption) { + let cancelledByExt = false; + + const context: DragExtensionInitializeContext = { + /** + * The elements that are being dragged + */ + elements: options.movingElements, + + preventDefault: () => { + cancelledByExt = true; + }, + + dragStartPos: Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([ + options.event.x, + options.event.y, + ]) + ), + }; + const extension = this.transformExtensions; + const activeExtensionHandlers = Array.from( + extension.values().map(ext => { + return ext.onDragInitialize(context); + }) + ); + + if (cancelledByExt) { + activeExtensionHandlers.forEach(handler => handler.clear?.()); + return; + } + + const host = this.std.host; + const { event } = options; + const internal = { + elements: context.elements.map(model => { + return { + view: this.gfx.view.get(model)!, + originalBound: Bound.deserialize(model.xywh), + model: model, + }; + }), + dragStartPos: Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y]) + ), + }; + let dragLastPos = internal.dragStartPos; + let lastEvent = event; + + const viewportWatcher = this.gfx.viewport.viewportMoved.subscribe(() => { + onDragMove(lastEvent as PointerEvent); + }); + const onDragMove = (event: PointerEvent) => { + dragLastPos = Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y]) + ); + + const moveContext: ExtensionDragMoveContext = { + ...internal, + event, + dragLastPos, + dx: dragLastPos.x - internal.dragStartPos.x, + dy: dragLastPos.y - internal.dragStartPos.y, + }; + + // If shift key is pressed, restrict the movement to one direction + if (this.keyboard.shiftKey$.peek()) { + const angle = Math.abs(Math.atan2(moveContext.dy, moveContext.dx)); + const direction = + angle < Math.PI / 4 || angle > 3 * (Math.PI / 4) ? 'dy' : 'dx'; + + moveContext[direction] = 0; + } + + this._safeExecute(() => { + activeExtensionHandlers.forEach(handler => + handler.onDragMove?.(moveContext) + ); + }, 'Error while executing extension `onDragMove`'); + + internal.elements.forEach(element => { + const { view, originalBound } = element; + + view.onDragMove({ + currentBound: originalBound, + dx: moveContext.dx, + dy: moveContext.dy, + elements: internal.elements, + }); + }); + }; + const onDragEnd = (event: PointerEvent) => { + host.removeEventListener('pointermove', onDragMove, false); + host.removeEventListener('pointerup', onDragEnd, false); + viewportWatcher.unsubscribe(); + + dragLastPos = Point.from( + this.gfx.viewport.toModelCoordFromClientCoord([event.x, event.y]) + ); + const endContext: ExtensionDragEndContext = { + ...internal, + event, + dragLastPos, + dx: dragLastPos.x - internal.dragStartPos.x, + dy: dragLastPos.y - internal.dragStartPos.y, + }; + + this._safeExecute(() => { + activeExtensionHandlers.forEach(handler => + handler.onDragEnd?.(endContext) + ); + }, 'Error while executing extension `onDragEnd` handler'); + + this.std.store.transact(() => { + internal.elements.forEach(element => { + const { view, originalBound } = element; + + view.onDragEnd({ + currentBound: originalBound.moveDelta(endContext.dx, endContext.dy), + dx: endContext.dx, + dy: endContext.dy, + elements: internal.elements, + }); + }); + }); + + this._safeExecute(() => { + activeExtensionHandlers.forEach(handler => handler.clear?.()); + }, 'Error while executing extension `clear` handler'); + + options.onDragEnd?.(); + }; + const listenEvent = () => { + host.addEventListener('pointermove', onDragMove, false); + host.addEventListener('pointerup', onDragEnd, false); + }; + const dragStart = () => { + internal.elements.forEach(({ view, originalBound }) => { + view.onDragStart({ + currentBound: originalBound, + elements: internal.elements, + }); + }); + + const dragStartContext: ExtensionDragStartContext = { + ...internal, + event: event as PointerEvent, + dragLastPos, + }; + + this._safeExecute(() => { + activeExtensionHandlers.forEach(handler => + handler.onDragStart?.(dragStartContext) + ); + }, 'Error while executing extension `onDragStart` handler'); + }; + + listenEvent(); + dragStart(); + } +} + +export const TransformExtensionIdentifier = + createIdentifier('element-transform-extension'); + +export class TransformExtension extends Extension { + static key: string; + + get std() { + return this.gfx.std; + } + + constructor(protected readonly gfx: GfxController) { + super(); + } + + mounted() {} + + unmounted() {} + + click(_: PointerEventState) {} + + dblClick(_: PointerEventState) {} + + pointerDown(_: PointerEventState) {} + + pointerMove(_: PointerEventState) {} + + pointerUp(_: PointerEventState) {} + + onDragInitialize(_: DragExtensionInitializeContext): { + onDragStart?: (context: ExtensionDragStartContext) => void; + onDragMove?: (context: ExtensionDragMoveContext) => void; + onDragEnd?: (context: ExtensionDragEndContext) => void; + clear?: () => void; + } { + return {}; + } + + static override setup(di: Container) { + if (!this.key) { + throw new BlockSuiteError( + ErrorCode.ValueNotExists, + 'key is not defined in the TransformExtension' + ); + } + + di.add( + this as unknown as { new (gfx: GfxController): TransformExtension }, + [GfxControllerIdentifier] + ); + di.addImpl(TransformExtensionIdentifier(this.key), provider => + provider.get(this) + ); + } +} diff --git a/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts b/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts new file mode 100644 index 0000000000..ab2d2da6ed --- /dev/null +++ b/blocksuite/framework/block-std/src/gfx/element-transform/view-transform.ts @@ -0,0 +1,43 @@ +import type { Bound } from '@blocksuite/global/gfx'; + +import type { GfxBlockComponent } from '../../view'; +import type { GfxModel } from '../model/model'; +import type { GfxElementModelView } from '../view/view'; + +export type DragStartContext = { + /** + * The elements that are being dragged + */ + elements: { + view: GfxBlockComponent | GfxElementModelView; + originalBound: Bound; + model: GfxModel; + }[]; + + /** + * The bound of element when drag starts + */ + currentBound: Bound; +}; + +export type DragMoveContext = DragStartContext & { + /** + * The delta x of current drag position compared to the start position in model coordinate. + */ + dx: number; + + /** + * The delta y of current drag position compared to the start position in model coordinate. + */ + dy: number; +}; + +export type DragEndContext = DragMoveContext; + +export type GfxViewTransformInterface = { + onDragStart: (context: DragStartContext) => void; + onDragMove: (context: DragMoveContext) => void; + onDragEnd: (context: DragEndContext) => void; + onRotate: (context: {}) => void; + onResize: (context: {}) => void; +}; diff --git a/blocksuite/framework/block-std/src/gfx/index.ts b/blocksuite/framework/block-std/src/gfx/index.ts index 8022689907..04ffbc72e0 100644 --- a/blocksuite/framework/block-std/src/gfx/index.ts +++ b/blocksuite/framework/block-std/src/gfx/index.ts @@ -12,6 +12,25 @@ export { } from '../utils/tree.js'; export { GfxController } from './controller.js'; export type { CursorType, StandardCursor } from './cursor.js'; +export type { + DragExtensionInitializeContext, + DragInitializationOption, + ExtensionDragEndContext, + ExtensionDragMoveContext, + ExtensionDragStartContext, +} from './element-transform/drag.js'; +export { CanvasEventHandler } from './element-transform/extension/canvas-event-handler.js'; +export { + ElementTransformManager, + TransformExtension, + TransformExtensionIdentifier, + TransformManagerIdentifier, +} from './element-transform/transform-manager.js'; +export type { + DragEndContext, + DragMoveContext, + DragStartContext, +} from './element-transform/view-transform.js'; export { GfxExtension, GfxExtensionIdentifier } from './extension.js'; export { GridManager } from './grid.js'; export { GfxControllerIdentifier } from './identifiers.js'; diff --git a/blocksuite/framework/block-std/src/gfx/view/view-manager.ts b/blocksuite/framework/block-std/src/gfx/view/view-manager.ts index 70078c163d..6ed2d487b4 100644 --- a/blocksuite/framework/block-std/src/gfx/view/view-manager.ts +++ b/blocksuite/framework/block-std/src/gfx/view/view-manager.ts @@ -1,9 +1,12 @@ import { DisposableGroup } from '@blocksuite/global/disposable'; import { onSurfaceAdded } from '../../utils/gfx.js'; +import { + type GfxBlockComponent, + isGfxBlockComponent, +} from '../../view/index.js'; import type { GfxController } from '../controller.js'; import { GfxExtension, GfxExtensionIdentifier } from '../extension.js'; -import { GfxBlockElementModel } from '../model/gfx-block-model.js'; import type { GfxModel } from '../model/model.js'; import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js'; import type { GfxLocalElementModel } from '../model/surface/local-element-model.js'; @@ -34,20 +37,22 @@ export class ViewManager extends GfxExtension { }); } - get(model: GfxModel | GfxLocalElementModel | string) { - if (typeof model === 'string') { - if (this._viewMap.has(model)) { - return this._viewMap.get(model); - } + get( + model: GfxModel | GfxLocalElementModel | string + ): GfxElementModelView | GfxBlockComponent | null { + model = typeof model === 'string' ? model : model.id; - return this.std.view.getBlock(model) ?? null; - } else { - if (model instanceof GfxBlockElementModel) { - return this.std.view.getBlock(model.id) ?? null; - } else { - return this._viewMap.get(model.id) ?? null; - } + if (this._viewMap.has(model)) { + return this._viewMap.get(model)!; } + + const blockView = this.std.view.getBlock(model); + + if (blockView && isGfxBlockComponent(blockView)) { + return blockView; + } + + return null; } override mounted(): void { diff --git a/blocksuite/framework/block-std/src/gfx/view/view.ts b/blocksuite/framework/block-std/src/gfx/view/view.ts index 3b934158ae..eae50835a7 100644 --- a/blocksuite/framework/block-std/src/gfx/view/view.ts +++ b/blocksuite/framework/block-std/src/gfx/view/view.ts @@ -6,9 +6,15 @@ import type { Extension } from '@blocksuite/store'; import type { PointerEventState } from '../../event/index.js'; import type { EditorHost } from '../../view/index.js'; +import type { + DragEndContext, + DragMoveContext, + DragStartContext, + GfxViewTransformInterface, +} from '../element-transform/view-transform.js'; import type { GfxController } from '../index.js'; import type { GfxElementGeometry, PointTestOptions } from '../model/base.js'; -import type { GfxPrimitiveElementModel } from '../model/surface/element-model.js'; +import { GfxPrimitiveElementModel } from '../model/surface/element-model.js'; import type { GfxLocalElementModel } from '../model/surface/local-element-model.js'; export type EventsHandlerMap = { @@ -33,7 +39,7 @@ export class GfxElementModelView< | GfxLocalElementModel, RendererContext = object, > - implements GfxElementGeometry, Extension + implements GfxElementGeometry, Extension, GfxViewTransformInterface { static type: string; @@ -166,6 +172,26 @@ export class GfxElementModelView< onCreated() {} + onDragStart(_: DragStartContext) { + if (this.model instanceof GfxPrimitiveElementModel) { + this.model.stash('xywh'); + } + } + + onDragEnd(_: DragEndContext) { + if (this.model instanceof GfxPrimitiveElementModel) { + this.model.pop('xywh'); + } + } + + onDragMove({ dx, dy, currentBound }: DragMoveContext) { + this.model.xywh = currentBound.moveDelta(dx, dy).serialize(); + } + + onResize = () => {}; + + onRotate = () => {}; + /** * Called when the view is destroyed. * Override this method requires calling `super.onDestroyed()`. diff --git a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts index 7b0563a078..b83b2e0355 100644 --- a/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts +++ b/blocksuite/framework/block-std/src/view/element/gfx-block-component.ts @@ -4,6 +4,10 @@ import { computed } from '@preact/signals-core'; import { nothing } from 'lit'; import type { BlockService } from '../../extension/index.js'; +import type { + DragMoveContext, + GfxViewTransformInterface, +} from '../../gfx/element-transform/view-transform.js'; import { GfxControllerIdentifier } from '../../gfx/identifiers.js'; import type { GfxBlockElementModel } from '../../gfx/index.js'; import { SurfaceSelection } from '../../selection/index.js'; @@ -47,10 +51,13 @@ function handleGfxConnection(instance: GfxBlockComponent) { } export abstract class GfxBlockComponent< - Model extends GfxBlockElementModel = GfxBlockElementModel, - Service extends BlockService = BlockService, - WidgetName extends string = string, -> extends BlockComponent { + Model extends GfxBlockElementModel = GfxBlockElementModel, + Service extends BlockService = BlockService, + WidgetName extends string = string, + > + extends BlockComponent + implements GfxViewTransformInterface +{ [GfxElementSymbol] = true; get gfx() { @@ -62,6 +69,22 @@ export abstract class GfxBlockComponent< handleGfxConnection(this); } + onDragMove = ({ dx, dy, currentBound }: DragMoveContext) => { + this.model.xywh = currentBound.moveDelta(dx, dy).serialize(); + }; + + onDragStart() { + this.model.stash('xywh'); + } + + onDragEnd() { + this.model.pop('xywh'); + } + + onRotate() {} + + onResize() {} + getCSSTransform() { const viewport = this.gfx.viewport; const { translateX, translateY, zoom } = viewport; @@ -151,6 +174,22 @@ export function toGfxBlockComponent< return selection.is(SurfaceSelection); }); + onDragMove({ dx, dy, currentBound }: DragMoveContext) { + this.model.xywh = currentBound.moveDelta(dx, dy).serialize(); + } + + onDragStart() { + this.model.stash('xywh'); + } + + onDragEnd() { + this.model.pop('xywh'); + } + + onRotate() {} + + onResize() {} + get gfx() { return this.std.get(GfxControllerIdentifier); } diff --git a/tests/blocksuite/e2e/fragments/frame-panel.spec.ts b/tests/blocksuite/e2e/fragments/frame-panel.spec.ts index 6962bddefd..eea8b43f3d 100644 --- a/tests/blocksuite/e2e/fragments/frame-panel.spec.ts +++ b/tests/blocksuite/e2e/fragments/frame-panel.spec.ts @@ -207,10 +207,7 @@ test.describe('frame panel', () => { end: { x: number; y: number }, comparison: 'greaterThan' | 'lessThan' ) { - await page.mouse.move(start.x, start.y); - await page.mouse.down(); - await page.mouse.move(end.x, end.y); - await page.mouse.up(); + await dragBetweenCoords(page, start, end); await waitNextFrame(page); const newNoteRect = await edgelessNote.boundingBox(); diff --git a/tests/kit/src/utils/editor.ts b/tests/kit/src/utils/editor.ts index c05742555c..79417dbc08 100644 --- a/tests/kit/src/utils/editor.ts +++ b/tests/kit/src/utils/editor.ts @@ -201,11 +201,12 @@ export async function dragView( to: IVec, editorIndex = 0 ) { + const steps = 10; const [x1, y1] = await toViewCoord(page, from, editorIndex); const [x2, y2] = await toViewCoord(page, to, editorIndex); await page.mouse.move(x1, y1); await page.mouse.down(); - await page.mouse.move(x2, y2); + await page.mouse.move(x2, y2, { steps }); await page.mouse.up(); }