mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-23 21:06:22 +08:00
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
This commit is contained in:
+39
@@ -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 {};
|
||||
}
|
||||
}
|
||||
+87
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import {
|
||||
MindmapUtils,
|
||||
NODE_HORIZONTAL_SPACING,
|
||||
NODE_VERTICAL_SPACING,
|
||||
OverlayIdentifier,
|
||||
type SurfaceBlockComponent,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
type LayoutType,
|
||||
type LocalConnectorElementModel,
|
||||
MindmapElementModel,
|
||||
type MindmapNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
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';
|
||||
import { isMindmapNode } from '../utils/query';
|
||||
import { calculateResponseArea } from './utils/drag-utils';
|
||||
import type { MindMapIndicatorOverlay } from './utils/indicator-overlay';
|
||||
|
||||
type DragMindMapCtx = {
|
||||
mindmap: MindmapElementModel;
|
||||
node: MindmapNode;
|
||||
/**
|
||||
* Whether the dragged node is the root node of the mind map
|
||||
*/
|
||||
isRoot: boolean;
|
||||
originalMindMapBound: Bound;
|
||||
};
|
||||
|
||||
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<MindmapElementModel>();
|
||||
|
||||
private get _indicatorOverlay() {
|
||||
return this.std.getOptional(
|
||||
OverlayIdentifier('mindmap-indicator')
|
||||
) as MindMapIndicatorOverlay | null;
|
||||
}
|
||||
|
||||
private _calcDragResponseArea(mindmap: MindmapElementModel) {
|
||||
calculateResponseArea(mindmap);
|
||||
this._responseAreaUpdated.add(mindmap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handlers that can drag and drop mind map nodes
|
||||
* @param dragMindMapCtx
|
||||
* @param dragState
|
||||
* @returns
|
||||
*/
|
||||
private _createManipulationHandlers(dragMindMapCtx: DragMindMapCtx): {
|
||||
onDragMove?: (context: ExtensionDragMoveContext) => void;
|
||||
onDragEnd?: (context: ExtensionDragEndContext) => void;
|
||||
} {
|
||||
let hoveredCtx: {
|
||||
mindmap: MindmapElementModel | null;
|
||||
node: MindmapNode | null;
|
||||
detach?: boolean;
|
||||
abort?: () => void;
|
||||
merge?: () => void;
|
||||
} | null = null;
|
||||
|
||||
return {
|
||||
onDragMove: (context: ExtensionDragMoveContext) => {
|
||||
const { x, y } = context.dragLastPos;
|
||||
const hoveredMindMap = this._getHoveredMindMap([x, y], dragMindMapCtx);
|
||||
const indicator = this._indicatorOverlay;
|
||||
|
||||
if (indicator) {
|
||||
indicator.currentDragPos = [x, y];
|
||||
indicator.refresh();
|
||||
}
|
||||
|
||||
hoveredCtx?.abort?.();
|
||||
|
||||
const hoveredNode = hoveredMindMap
|
||||
? MindmapUtils.findTargetNode(hoveredMindMap, [x, y])
|
||||
: null;
|
||||
|
||||
hoveredCtx = {
|
||||
mindmap: hoveredMindMap,
|
||||
node: hoveredNode,
|
||||
};
|
||||
|
||||
// hovered on the currently dragged mind map but
|
||||
// 1. not hovered on any node or
|
||||
// 2. hovered on the node that is itself or its children (which is not allowed)
|
||||
// then consider user is trying to drop the node to its original position
|
||||
if (
|
||||
hoveredNode &&
|
||||
hoveredMindMap &&
|
||||
!MindmapUtils.containsNode(
|
||||
hoveredMindMap,
|
||||
hoveredNode,
|
||||
dragMindMapCtx.node
|
||||
)
|
||||
) {
|
||||
const operation = MindmapUtils.tryMoveNode(
|
||||
hoveredMindMap,
|
||||
hoveredNode,
|
||||
dragMindMapCtx.mindmap,
|
||||
dragMindMapCtx.node,
|
||||
[x, y],
|
||||
options => this._drawIndicator(options)
|
||||
);
|
||||
|
||||
if (operation) {
|
||||
hoveredCtx.abort = operation.abort;
|
||||
hoveredCtx.merge = operation.merge;
|
||||
}
|
||||
} else if (dragMindMapCtx.isRoot) {
|
||||
dragMindMapCtx.mindmap.layout();
|
||||
hoveredCtx.merge = () => {
|
||||
dragMindMapCtx.mindmap.layout();
|
||||
};
|
||||
} else {
|
||||
// if `hoveredMindMap` is not null
|
||||
// either the node is hovered on the dragged node's children
|
||||
// or the there is no hovered node at all
|
||||
// then consider user is trying to place the node to its original position
|
||||
if (hoveredMindMap) {
|
||||
const { node: draggedNode, mindmap } = dragMindMapCtx;
|
||||
const nodeBound = draggedNode.element.elementBound;
|
||||
|
||||
hoveredCtx.abort = this._drawIndicator({
|
||||
targetMindMap: mindmap,
|
||||
target: draggedNode,
|
||||
sourceMindMap: mindmap,
|
||||
source: draggedNode,
|
||||
newParent: draggedNode.parent!,
|
||||
insertPosition: {
|
||||
type: 'sibling',
|
||||
layoutDir: mindmap.getLayoutDir(draggedNode) as Exclude<
|
||||
LayoutType,
|
||||
LayoutType.BALANCE
|
||||
>,
|
||||
position: y > nodeBound.y + nodeBound.h / 2 ? 'next' : 'prev',
|
||||
},
|
||||
path: mindmap.getPath(draggedNode),
|
||||
});
|
||||
} else {
|
||||
hoveredCtx.detach = true;
|
||||
|
||||
const reset = (hoveredCtx.abort = MindmapUtils.hideNodeConnector(
|
||||
dragMindMapCtx.mindmap,
|
||||
dragMindMapCtx.node
|
||||
));
|
||||
|
||||
hoveredCtx.abort = () => {
|
||||
reset?.();
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd: (dragEndContext: ExtensionDragEndContext) => {
|
||||
if (hoveredCtx?.merge) {
|
||||
hoveredCtx.merge();
|
||||
} else {
|
||||
hoveredCtx?.abort?.();
|
||||
|
||||
if (hoveredCtx?.detach) {
|
||||
const { x: startX, y: startY } = dragEndContext.dragStartPos;
|
||||
const { x: endX, y: endY } = dragEndContext.dragLastPos;
|
||||
|
||||
dragMindMapCtx.node.element.xywh =
|
||||
dragMindMapCtx.node.element.elementBound
|
||||
.moveDelta(endX - startX, endY - startY)
|
||||
.serialize();
|
||||
|
||||
if (dragMindMapCtx.node !== dragMindMapCtx.mindmap.tree) {
|
||||
MindmapUtils.detachMindmap(
|
||||
dragMindMapCtx.mindmap,
|
||||
dragMindMapCtx.node
|
||||
);
|
||||
const mindmap = MindmapUtils.createFromTree(
|
||||
dragMindMapCtx.node,
|
||||
dragMindMapCtx.mindmap.style,
|
||||
dragMindMapCtx.mindmap.layoutType,
|
||||
this.gfx.surface!
|
||||
);
|
||||
|
||||
mindmap.layout();
|
||||
} else {
|
||||
dragMindMapCtx.mindmap.layout();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hoveredCtx = null;
|
||||
this._responseAreaUpdated.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create handlers that can translate entire mind map
|
||||
*/
|
||||
private _createTranslationHandlers(ctx: {
|
||||
mindmaps: Set<MindmapElementModel>;
|
||||
nodes: Set<GfxModel>;
|
||||
}): {
|
||||
onDragStart?: (context: ExtensionDragStartContext) => void;
|
||||
onDragMove?: (context: ExtensionDragMoveContext) => void;
|
||||
onDragEnd?: (context: ExtensionDragEndContext) => void;
|
||||
} {
|
||||
return {
|
||||
onDragStart: () => {
|
||||
ctx.nodes.forEach(node => {
|
||||
node.stash('xywh');
|
||||
});
|
||||
},
|
||||
onDragEnd: () => {
|
||||
ctx.mindmaps.forEach(mindmap => {
|
||||
mindmap.layout();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _drawIndicator(options: {
|
||||
targetMindMap: MindmapElementModel;
|
||||
target: MindmapNode;
|
||||
sourceMindMap: MindmapElementModel;
|
||||
source: MindmapNode;
|
||||
newParent: MindmapNode;
|
||||
insertPosition:
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
|
||||
path: number[];
|
||||
}) {
|
||||
const indicatorOverlay = this._indicatorOverlay;
|
||||
|
||||
if (!indicatorOverlay) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// draw the indicator at given position
|
||||
const { newParent, insertPosition, targetMindMap, target, source, path } =
|
||||
options;
|
||||
const children = newParent.children.filter(
|
||||
node => node.element.id !== source.id
|
||||
);
|
||||
|
||||
indicatorOverlay.setIndicatorInfo({
|
||||
targetMindMap,
|
||||
target,
|
||||
parent: newParent,
|
||||
insertPosition,
|
||||
parentChildren: children,
|
||||
path,
|
||||
});
|
||||
|
||||
return () => {
|
||||
indicatorOverlay.clear();
|
||||
};
|
||||
}
|
||||
|
||||
private _getHoveredMindMap(
|
||||
position: IVec,
|
||||
dragMindMapCtx: DragMindMapCtx
|
||||
): MindmapElementModel | null {
|
||||
const mindmap =
|
||||
(this.gfx
|
||||
.getElementByPoint(position[0], position[1], {
|
||||
all: true,
|
||||
responsePadding: [NODE_HORIZONTAL_SPACING, NODE_VERTICAL_SPACING * 2],
|
||||
})
|
||||
.find(el => {
|
||||
if (!(el instanceof MindmapElementModel)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
el === dragMindMapCtx.mindmap &&
|
||||
!dragMindMapCtx.originalMindMapBound.containsPoint(position)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as MindmapElementModel) ?? null;
|
||||
|
||||
if (
|
||||
mindmap &&
|
||||
(!this._responseAreaUpdated.has(mindmap) || !mindmap.tree.responseArea)
|
||||
) {
|
||||
this._calcDragResponseArea(mindmap);
|
||||
}
|
||||
|
||||
return mindmap;
|
||||
}
|
||||
|
||||
private _setupDragNodeImage(
|
||||
mindmapNode: MindmapNode,
|
||||
pos: { x: number; y: number }
|
||||
) {
|
||||
const surfaceBlock = this.gfx.surfaceComponent as SurfaceBlockComponent;
|
||||
const renderer = surfaceBlock?.renderer;
|
||||
const indicatorOverlay = this._indicatorOverlay;
|
||||
|
||||
if (!renderer || !indicatorOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeBound = mindmapNode.element.elementBound;
|
||||
|
||||
const canvas = renderer.getCanvasByBound(
|
||||
mindmapNode.element.elementBound,
|
||||
[mindmapNode.element],
|
||||
undefined,
|
||||
undefined,
|
||||
false
|
||||
);
|
||||
|
||||
indicatorOverlay.dragNodePos = [nodeBound.x - pos.x, nodeBound.y - pos.y];
|
||||
indicatorOverlay.dragNodeImage = canvas;
|
||||
|
||||
return () => {
|
||||
indicatorOverlay.dragNodeImage = null;
|
||||
indicatorOverlay.currentDragPos = null;
|
||||
};
|
||||
}
|
||||
|
||||
private _updateNodeOpacity(
|
||||
mindmap: MindmapElementModel,
|
||||
mindNode: MindmapNode
|
||||
) {
|
||||
const OPACITY = 0.3;
|
||||
const updatedNodes = new Set<
|
||||
GfxPrimitiveElementModel | LocalConnectorElementModel
|
||||
>();
|
||||
const traverse = (node: MindmapNode, parent: MindmapNode | null) => {
|
||||
node.element.opacity = OPACITY;
|
||||
updatedNodes.add(node.element);
|
||||
|
||||
if (parent) {
|
||||
const connectorId = `#${parent.element.id}-${node.element.id}`;
|
||||
const connector = mindmap.connectors.get(connectorId);
|
||||
|
||||
if (connector) {
|
||||
connector.opacity = OPACITY;
|
||||
updatedNodes.add(connector);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.children.length) {
|
||||
node.children.forEach(child => traverse(child, node));
|
||||
}
|
||||
};
|
||||
|
||||
const parentNode = mindmap.getParentNode(mindNode.element.id) ?? null;
|
||||
|
||||
traverse(mindNode, parentNode);
|
||||
|
||||
return () => {
|
||||
updatedNodes.forEach(el => {
|
||||
el.opacity = 1;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
mindmapBound.x -= NODE_HORIZONTAL_SPACING;
|
||||
mindmapBound.y -= NODE_VERTICAL_SPACING * 2;
|
||||
mindmapBound.w += NODE_HORIZONTAL_SPACING * 2;
|
||||
mindmapBound.h += NODE_VERTICAL_SPACING * 4;
|
||||
|
||||
this._calcDragResponseArea(mindmap);
|
||||
|
||||
const clearDragStatus = isRoot
|
||||
? mindmap.stashTree(mindmapNode)
|
||||
: this._setupDragNodeImage(mindmapNode, context.dragStartPos);
|
||||
const clearOpacity = this._updateNodeOpacity(mindmap, mindmapNode);
|
||||
|
||||
if (!isRoot) {
|
||||
context.elements.splice(0, 1);
|
||||
}
|
||||
|
||||
const mindMapDragCtx: DragMindMapCtx = {
|
||||
mindmap,
|
||||
node: mindmapNode,
|
||||
isRoot,
|
||||
originalMindMapBound: mindmapBound,
|
||||
};
|
||||
|
||||
return {
|
||||
...this._createManipulationHandlers(mindMapDragCtx),
|
||||
clear() {
|
||||
clearOpacity();
|
||||
clearDragStatus?.();
|
||||
if (!isRoot) {
|
||||
context.elements.push(mindmapNode.element);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mindmapNodes = new Set<GfxModel>();
|
||||
const mindmaps = new Set<MindmapElementModel>();
|
||||
|
||||
context.elements.forEach(el => {
|
||||
if (isMindmapNode(el)) {
|
||||
const mindmap =
|
||||
el.group instanceof MindmapElementModel
|
||||
? el.group
|
||||
: (el as MindmapElementModel);
|
||||
|
||||
mindmaps.add(mindmap);
|
||||
mindmap.childElements.forEach(child => mindmapNodes.add(child));
|
||||
} else if (isGfxGroupCompatibleModel(el)) {
|
||||
el.descendantElements.forEach(desc => {
|
||||
if (desc.group instanceof MindmapElementModel) {
|
||||
mindmaps.add(desc.group);
|
||||
desc.group.childElements.forEach(_el => mindmapNodes.add(_el));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (mindmapNodes.size > 1) {
|
||||
mindmapNodes.forEach(node => context.elements.push(node));
|
||||
return this._createTranslationHandlers({
|
||||
mindmaps,
|
||||
nodes: mindmapNodes,
|
||||
});
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
NODE_HORIZONTAL_SPACING,
|
||||
NODE_VERTICAL_SPACING,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
LayoutType,
|
||||
type MindmapElementModel,
|
||||
type MindmapNode,
|
||||
type MindmapRoot,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { Bound } from '@blocksuite/global/gfx';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
const isOnEdge = (node: MindmapNode, direction: 'tail' | 'head') => {
|
||||
let current = node;
|
||||
|
||||
while (current) {
|
||||
if (!current.parent) return true;
|
||||
|
||||
if (direction === 'tail' && last(current.parent.children) === current) {
|
||||
current = current.parent;
|
||||
} else if (direction === 'head' && current.parent.children[0] === current) {
|
||||
current = current.parent;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const TAIL_RESPONSE_AREA = NODE_HORIZONTAL_SPACING;
|
||||
|
||||
const fillResponseArea = (
|
||||
node: MindmapNode,
|
||||
layoutType: LayoutType,
|
||||
parent: MindmapNode | null
|
||||
) => {
|
||||
// root node
|
||||
if (!parent) {
|
||||
const rootElmBound = node.element.elementBound;
|
||||
const width =
|
||||
layoutType === LayoutType.BALANCE
|
||||
? rootElmBound.w + TAIL_RESPONSE_AREA * 2
|
||||
: rootElmBound.w + TAIL_RESPONSE_AREA;
|
||||
|
||||
node.responseArea = new Bound(
|
||||
layoutType === LayoutType.BALANCE || layoutType === LayoutType.LEFT
|
||||
? rootElmBound.x - TAIL_RESPONSE_AREA
|
||||
: rootElmBound.x,
|
||||
rootElmBound.y,
|
||||
width,
|
||||
rootElmBound.h
|
||||
);
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutType === LayoutType.BALANCE) {
|
||||
(node as MindmapRoot).right.forEach(child => {
|
||||
fillResponseArea(child, LayoutType.RIGHT, node);
|
||||
});
|
||||
(node as MindmapRoot).left.forEach(child => {
|
||||
fillResponseArea(child, LayoutType.LEFT, node);
|
||||
});
|
||||
} else {
|
||||
node.children.forEach(child => {
|
||||
fillResponseArea(child, layoutType, node);
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
const nodeBound = node.element.elementBound;
|
||||
const idx = parent.children.indexOf(node) ?? -1;
|
||||
const isLast =
|
||||
idx === (parent.children.length || -1) - 1 && isOnEdge(node, 'tail');
|
||||
const isFirst = idx === 0 && isOnEdge(node, 'head');
|
||||
const upperSpacing = isFirst
|
||||
? NODE_VERTICAL_SPACING * 2
|
||||
: NODE_VERTICAL_SPACING / 2;
|
||||
const lowerSpacing = isLast
|
||||
? NODE_VERTICAL_SPACING * 2
|
||||
: NODE_VERTICAL_SPACING / 2;
|
||||
|
||||
const h = nodeBound.h + upperSpacing + lowerSpacing;
|
||||
const w =
|
||||
(layoutType === LayoutType.RIGHT
|
||||
? node.element.x +
|
||||
node.element.w -
|
||||
(parent.element.x + parent.element.w)
|
||||
: parent.element.x - node.element.x) + TAIL_RESPONSE_AREA;
|
||||
|
||||
node.responseArea = new Bound(
|
||||
layoutType === LayoutType.RIGHT
|
||||
? parent.element.x + parent.element.w
|
||||
: parent.element.x - w,
|
||||
node.element.y - upperSpacing,
|
||||
w,
|
||||
h
|
||||
);
|
||||
|
||||
if (node.children.length > 0 && !node.detail.collapsed) {
|
||||
let responseArea: Bound;
|
||||
|
||||
node.children.forEach(child => {
|
||||
fillResponseArea(child, layoutType, node);
|
||||
|
||||
if (responseArea) {
|
||||
responseArea = responseArea.unite(child.responseArea!);
|
||||
} else {
|
||||
responseArea = child.responseArea!;
|
||||
}
|
||||
});
|
||||
|
||||
node.responseArea.h = responseArea!.h;
|
||||
node.responseArea.y = responseArea!.y;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const balanceLeftRightResponseArea = (tree: MindmapRoot) => {
|
||||
const leftTreeArea = tree.left.reduce((pre: Bound | null, node) => {
|
||||
if (pre) {
|
||||
return pre.unite(node.responseArea!);
|
||||
}
|
||||
return node.responseArea!;
|
||||
}, null);
|
||||
const rightTreeArea = tree.right.reduce((pre: Bound | null, node) => {
|
||||
if (pre) {
|
||||
return pre.unite(node.responseArea!);
|
||||
}
|
||||
return node.responseArea!;
|
||||
}, null);
|
||||
|
||||
if (!leftTreeArea || !rightTreeArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if the height of the left tree and right tree are not equal
|
||||
// expand the response area of lower tree to match the height of the higher tree
|
||||
if (leftTreeArea.h !== rightTreeArea.h) {
|
||||
const isLeftHigher = leftTreeArea.h > rightTreeArea.h;
|
||||
const upperBoundary = isLeftHigher ? leftTreeArea.y : rightTreeArea.y;
|
||||
const bottomBoundary = isLeftHigher
|
||||
? leftTreeArea.y + leftTreeArea.h
|
||||
: rightTreeArea.y + rightTreeArea.h;
|
||||
const targetChildren = isLeftHigher ? tree.right : tree.left;
|
||||
|
||||
const expandEdge = (children: MindmapNode[]) => {
|
||||
const expand = (direction: 'up' | 'down') => {
|
||||
const expandUpperEdge = direction === 'up';
|
||||
const node = direction === 'up' ? children[0] : last(children)!;
|
||||
|
||||
if (!node) return;
|
||||
|
||||
if (node.responseArea) {
|
||||
node.responseArea.h = expandUpperEdge
|
||||
? node.responseArea.h + (node.responseArea.y - upperBoundary)
|
||||
: node.responseArea.h +
|
||||
(bottomBoundary - node.responseArea.y - node.responseArea.h);
|
||||
expandUpperEdge && (node.responseArea.y = upperBoundary);
|
||||
}
|
||||
};
|
||||
|
||||
expand('up');
|
||||
expand('down');
|
||||
};
|
||||
|
||||
expandEdge(targetChildren);
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateResponseArea = (mindmap: MindmapElementModel) => {
|
||||
const layoutDir = mindmap.layoutType;
|
||||
const tree = mindmap.tree;
|
||||
|
||||
switch (layoutDir) {
|
||||
case LayoutType.RIGHT:
|
||||
case LayoutType.LEFT:
|
||||
{
|
||||
fillResponseArea(tree, layoutDir, null);
|
||||
}
|
||||
break;
|
||||
case LayoutType.BALANCE:
|
||||
{
|
||||
fillResponseArea(tree, LayoutType.BALANCE, null);
|
||||
balanceLeftRightResponseArea(tree);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
NODE_HORIZONTAL_SPACING,
|
||||
NODE_VERTICAL_SPACING,
|
||||
Overlay,
|
||||
PathGenerator,
|
||||
} from '@blocksuite/affine-block-surface';
|
||||
import {
|
||||
ConnectorMode,
|
||||
LayoutType,
|
||||
type MindmapElementModel,
|
||||
type MindmapNode,
|
||||
} from '@blocksuite/affine-model';
|
||||
import { ThemeProvider } from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
type Bound,
|
||||
isVecZero,
|
||||
type IVec,
|
||||
PointLocation,
|
||||
toRadian,
|
||||
Vec,
|
||||
} from '@blocksuite/global/gfx';
|
||||
import last from 'lodash-es/last';
|
||||
|
||||
export class MindMapIndicatorOverlay extends Overlay {
|
||||
static INDICATOR_SIZE = [48, 22];
|
||||
|
||||
static override overlayName: string = 'mindmap-indicator';
|
||||
|
||||
currentDragPos: IVec | null = null;
|
||||
|
||||
direction: LayoutType.LEFT | LayoutType.RIGHT = LayoutType.RIGHT;
|
||||
|
||||
dragNodeImage: HTMLCanvasElement | null = null;
|
||||
|
||||
dragNodePos: IVec = [0, 0];
|
||||
|
||||
mode: ConnectorMode = ConnectorMode.Straight;
|
||||
|
||||
parentBound: Bound | null = null;
|
||||
|
||||
pathGen = new PathGenerator();
|
||||
|
||||
targetBound: Bound | null = null;
|
||||
|
||||
get themeService() {
|
||||
return this.gfx.std.get(ThemeProvider);
|
||||
}
|
||||
|
||||
private _generatePath() {
|
||||
const startRelativePos =
|
||||
this.direction === LayoutType.RIGHT
|
||||
? PointLocation.fromVec([1, 0.5])
|
||||
: PointLocation.fromVec([0, 0.5]);
|
||||
const endRelativePos =
|
||||
this.direction === LayoutType.RIGHT
|
||||
? PointLocation.fromVec([0, 0.5])
|
||||
: PointLocation.fromVec([1, 0.5]);
|
||||
const { parentBound, targetBound: newPosBound } = this;
|
||||
|
||||
if (this.mode === ConnectorMode.Orthogonal) {
|
||||
return this.pathGen
|
||||
.generateOrthogonalConnectorPath({
|
||||
startPoint: this._getRelativePoint(parentBound!, startRelativePos),
|
||||
endPoint: this._getRelativePoint(newPosBound!, endRelativePos),
|
||||
startBound: parentBound,
|
||||
endBound: newPosBound,
|
||||
})
|
||||
.map(p => new PointLocation(p));
|
||||
} else if (this.mode === ConnectorMode.Curve) {
|
||||
const startPoint = this._getRelativePoint(
|
||||
this.parentBound!,
|
||||
startRelativePos
|
||||
);
|
||||
const endPoint = this._getRelativePoint(
|
||||
this.targetBound!,
|
||||
endRelativePos
|
||||
);
|
||||
|
||||
const startTangentVertical = Vec.rot(startPoint.tangent, -Math.PI / 2);
|
||||
startPoint.out = Vec.mul(
|
||||
startTangentVertical,
|
||||
Math.max(
|
||||
100,
|
||||
Math.abs(
|
||||
Vec.pry(Vec.sub(endPoint, startPoint), startTangentVertical)
|
||||
) / 3
|
||||
)
|
||||
);
|
||||
|
||||
const endTangentVertical = Vec.rot(endPoint.tangent, -Math.PI / 2);
|
||||
endPoint.in = Vec.mul(
|
||||
endTangentVertical,
|
||||
Math.max(
|
||||
100,
|
||||
Math.abs(Vec.pry(Vec.sub(startPoint, endPoint), endTangentVertical)) /
|
||||
3
|
||||
)
|
||||
);
|
||||
|
||||
return [startPoint, endPoint];
|
||||
} else {
|
||||
const startPoint = new PointLocation(
|
||||
this.parentBound!.getRelativePoint(startRelativePos)
|
||||
);
|
||||
const endPoint = new PointLocation(
|
||||
this.targetBound!.getRelativePoint(endRelativePos)
|
||||
);
|
||||
|
||||
return [startPoint, endPoint];
|
||||
}
|
||||
}
|
||||
|
||||
private _getRelativePoint(bound: Bound, position: IVec) {
|
||||
const location = new PointLocation(
|
||||
bound.getRelativePoint(position as IVec)
|
||||
);
|
||||
|
||||
if (isVecZero(Vec.sub(position, [0, 0.5])))
|
||||
location.tangent = Vec.rot([0, -1], toRadian(0));
|
||||
else if (isVecZero(Vec.sub(position, [1, 0.5])))
|
||||
location.tangent = Vec.rot([0, 1], toRadian(0));
|
||||
else if (isVecZero(Vec.sub(position, [0.5, 0])))
|
||||
location.tangent = Vec.rot([1, 0], toRadian(0));
|
||||
else if (isVecZero(Vec.sub(position, [0.5, 1])))
|
||||
location.tangent = Vec.rot([-1, 0], toRadian(0));
|
||||
|
||||
return location;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use to calculate the position of the indicator given its sibling's bound
|
||||
* @param siblingBound
|
||||
* @param direction
|
||||
*/
|
||||
private _moveRelativeToBound(
|
||||
siblingBound: Bound,
|
||||
direction: 'up' | 'down',
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>
|
||||
) {
|
||||
const isLeftLayout = layoutDir === LayoutType.LEFT;
|
||||
const isUpDirection = direction === 'up';
|
||||
|
||||
return siblingBound.moveDelta(
|
||||
isLeftLayout
|
||||
? siblingBound.w - MindMapIndicatorOverlay.INDICATOR_SIZE[0]
|
||||
: 0,
|
||||
isUpDirection
|
||||
? -(
|
||||
NODE_VERTICAL_SPACING / 2 +
|
||||
MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
|
||||
)
|
||||
: siblingBound.h +
|
||||
NODE_VERTICAL_SPACING / 2 -
|
||||
MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
|
||||
);
|
||||
}
|
||||
|
||||
override clear() {
|
||||
this.targetBound = null;
|
||||
this.parentBound = null;
|
||||
}
|
||||
|
||||
override render(ctx: CanvasRenderingContext2D): void {
|
||||
if (this.currentDragPos && this.dragNodeImage) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.drawImage(
|
||||
this.dragNodeImage,
|
||||
this.currentDragPos[0] + this.dragNodePos[0],
|
||||
this.currentDragPos[1] + this.dragNodePos[1],
|
||||
this.dragNodeImage.width / 2,
|
||||
this.dragNodeImage.height / 2
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (!this.parentBound || !this.targetBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPos = this.targetBound;
|
||||
const points = this._generatePath();
|
||||
const color = this.themeService.getColorValue(
|
||||
'--affine-primary-color',
|
||||
'#1E96EB',
|
||||
true
|
||||
);
|
||||
|
||||
ctx.strokeStyle = color;
|
||||
ctx.fillStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(targetPos.x, targetPos.y, targetPos.w, targetPos.h, 4);
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(points[0][0], points[0][1]);
|
||||
|
||||
if (this.mode === ConnectorMode.Curve) {
|
||||
points.forEach((point, idx) => {
|
||||
if (idx === 0) return;
|
||||
const last = points[idx - 1];
|
||||
ctx.bezierCurveTo(
|
||||
last.absOut[0],
|
||||
last.absOut[1],
|
||||
point.absIn[0],
|
||||
point.absIn[1],
|
||||
point[0],
|
||||
point[1]
|
||||
);
|
||||
});
|
||||
} else {
|
||||
points.forEach((point, idx) => {
|
||||
if (idx === 0) return;
|
||||
ctx.lineTo(point[0], point[1]);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
setIndicatorInfo(options: {
|
||||
targetMindMap: MindmapElementModel;
|
||||
target: MindmapNode;
|
||||
parent: MindmapNode;
|
||||
parentChildren: MindmapNode[];
|
||||
insertPosition:
|
||||
| {
|
||||
type: 'sibling';
|
||||
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
|
||||
position: 'prev' | 'next';
|
||||
}
|
||||
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> };
|
||||
path: number[];
|
||||
}) {
|
||||
const {
|
||||
insertPosition,
|
||||
parent,
|
||||
parentChildren,
|
||||
targetMindMap,
|
||||
target,
|
||||
path,
|
||||
} = options;
|
||||
|
||||
const parentBound = parent.element.elementBound;
|
||||
const isBalancedMindMap = targetMindMap.layoutType === LayoutType.BALANCE;
|
||||
const isLeftLayout = insertPosition.layoutDir === LayoutType.LEFT;
|
||||
const isFirstLevel = path.length === 2;
|
||||
|
||||
this.direction = insertPosition.layoutDir;
|
||||
this.parentBound = parentBound;
|
||||
|
||||
if (insertPosition.type === 'sibling') {
|
||||
const targetBound = target.element.elementBound;
|
||||
|
||||
this.targetBound =
|
||||
isBalancedMindMap && isFirstLevel && isLeftLayout
|
||||
? this._moveRelativeToBound(
|
||||
targetBound,
|
||||
insertPosition.position === 'next' ? 'up' : 'down',
|
||||
insertPosition.layoutDir
|
||||
)
|
||||
: this._moveRelativeToBound(
|
||||
targetBound,
|
||||
insertPosition.position === 'next' ? 'down' : 'up',
|
||||
insertPosition.layoutDir
|
||||
);
|
||||
} else {
|
||||
if (parentChildren.length === 0 || parent.detail.collapsed) {
|
||||
this.targetBound = parentBound.moveDelta(
|
||||
(isLeftLayout ? -1 : 1) *
|
||||
(NODE_HORIZONTAL_SPACING / 2 + parentBound.w),
|
||||
parentBound.h / 2 - MindMapIndicatorOverlay.INDICATOR_SIZE[1] / 2
|
||||
);
|
||||
} else {
|
||||
const lastChildBound = last(parentChildren)!.element.elementBound;
|
||||
|
||||
this.targetBound =
|
||||
isBalancedMindMap && isFirstLevel && isLeftLayout
|
||||
? this._moveRelativeToBound(
|
||||
lastChildBound,
|
||||
'up',
|
||||
insertPosition.layoutDir
|
||||
)
|
||||
: this._moveRelativeToBound(
|
||||
lastChildBound,
|
||||
'down',
|
||||
insertPosition.layoutDir
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.targetBound.w = MindMapIndicatorOverlay.INDICATOR_SIZE[0];
|
||||
this.targetBound.h = MindMapIndicatorOverlay.INDICATOR_SIZE[1];
|
||||
|
||||
this.mode = targetMindMap.styleGetter.getNodeStyle(
|
||||
target,
|
||||
options.path
|
||||
).connector.mode;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user