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(); }