snapshot of https://github.com/toeverything/AFFiNE.git (canary, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build & Test / Analyze (javascript, affine) (push) Failing after 4s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 4s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 3s
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 4s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 3s
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Release / Canary Gate (push) Has been skipped
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Copilot Test Automatically / Setup Test (push) Failing after 1s
Release / Prepare (push) Failing after 2s
Release / Release Cloud (push) Has been skipped
Release / Release Desktop (push) Has been skipped
Release / Release Docker Image (push) Has been skipped
Release / Release Mobile (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 04:01:20 +05:00
commit 702cc698a7
9990 changed files with 1073618 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
{
"name": "@blocksuite/affine-gfx-mindmap",
"description": "Gfx mindmap for BlockSuite.",
"type": "module",
"scripts": {
"build": "yarn run -T tsc"
},
"sideEffects": false,
"keywords": [],
"author": "toeverything",
"license": "MIT",
"dependencies": {
"@blocksuite/affine-block-attachment": "workspace:*",
"@blocksuite/affine-block-image": "workspace:*",
"@blocksuite/affine-block-surface": "workspace:*",
"@blocksuite/affine-components": "workspace:*",
"@blocksuite/affine-ext-loader": "workspace:*",
"@blocksuite/affine-gfx-connector": "workspace:*",
"@blocksuite/affine-gfx-pointer": "workspace:*",
"@blocksuite/affine-gfx-shape": "workspace:*",
"@blocksuite/affine-gfx-text": "workspace:*",
"@blocksuite/affine-model": "workspace:*",
"@blocksuite/affine-shared": "workspace:*",
"@blocksuite/affine-widget-edgeless-toolbar": "workspace:*",
"@blocksuite/global": "workspace:*",
"@blocksuite/icons": "^2.2.17",
"@blocksuite/std": "workspace:*",
"@blocksuite/store": "workspace:*",
"@lit/context": "^1.1.2",
"@preact/signals-core": "^1.8.0",
"@types/lodash-es": "^4.17.12",
"lit": "^3.2.0",
"lodash-es": "^4.17.23",
"simple-xml-to-json": "^1.2.2",
"yjs": "^13.6.27"
},
"exports": {
".": "./src/index.ts",
"./view": "./src/view.ts",
"./store": "./src/store.ts"
},
"files": [
"src",
"dist",
"!src/__tests__",
"!dist/__tests__"
],
"version": "0.27.5"
}

View File

@@ -0,0 +1,2 @@
export * from './markdown';
export * from './plain-text';

View File

@@ -0,0 +1,69 @@
import { ElementToMarkdownAdapterExtension } from '@blocksuite/affine-block-surface';
import { getShapeText } from '@blocksuite/affine-gfx-shape';
import {
buildMindMapTree,
type MindMapTreeNode,
} from '@blocksuite/affine-model';
export const mindmapToMarkdownAdapterMatcher =
ElementToMarkdownAdapterExtension({
name: 'mindmap',
match: elementModel => elementModel.type === 'mindmap',
toAST: (elementModel, context) => {
if (elementModel.type !== 'mindmap') {
return null;
}
const mindMapTree = buildMindMapTree(elementModel);
if (!mindMapTree) {
return null;
}
const { walkerContext, elements } = context;
const traverseMindMapTree = (node: MindMapTreeNode) => {
const shapeElement = elements[node.id as string];
const shapeText = getShapeText(shapeElement);
walkerContext
.openNode(
{
type: 'listItem',
spread: false,
children: [],
},
'children'
)
.openNode(
{
type: 'paragraph',
children: [
{
type: 'text',
value: shapeText,
},
],
},
'children'
)
.closeNode();
node.children.forEach(child => {
traverseMindMapTree(child);
walkerContext.closeNode();
});
};
// First create a list node for the mind map tree
walkerContext.openNode(
{
type: 'list',
ordered: false,
spread: false,
children: [],
},
'children'
);
traverseMindMapTree(mindMapTree);
walkerContext.closeNode().closeNode();
return null;
},
});

View File

@@ -0,0 +1,13 @@
import { ElementToPlainTextAdapterExtension } from '@blocksuite/affine-block-surface';
import { getMindMapTreeText } from '../utils';
export const mindmapToPlainTextAdapterMatcher =
ElementToPlainTextAdapterExtension({
name: 'mindmap',
match: elementModel => elementModel.type === 'mindmap',
toAST: (elementModel, context) => {
const mindMapContent = getMindMapTreeText(elementModel, context.elements);
return { content: mindMapContent };
},
});

View File

@@ -0,0 +1,65 @@
import { getShapeText } from '@blocksuite/affine-gfx-shape';
import {
buildMindMapTree,
type MindMapTreeNode,
} from '@blocksuite/affine-model';
/**
* traverse the mindMapTree and construct the content string
* like:
* - Root
* - Child 1
* - Child 1.1
* - Child 1.2
* - Child 2
* - Child 2.1
* - Child 2.2
* - Child 3
* - Child 3.1
* - Child 3.2
* @param elementModel - the mindmap element model
* @param elements - the elements map
* @returns the mindmap tree text
*/
export function getMindMapTreeText(
elementModel: Record<string, unknown>,
elements: Record<string, Record<string, unknown>>,
options: {
prefix: string;
repeat: number;
} = {
prefix: ' ',
repeat: 2,
}
): string {
let mindMapContent = '';
if (elementModel.type !== 'mindmap') {
return mindMapContent;
}
const mindMapTree = buildMindMapTree(elementModel);
if (!mindMapTree) {
return mindMapContent;
}
let layer = 0;
const traverseMindMapTree = (
node: MindMapTreeNode,
prefix: string,
repeat: number
) => {
const shapeElement = elements[node.id as string];
const shapeText = getShapeText(shapeElement);
if (shapeElement) {
mindMapContent += `${prefix.repeat(layer * repeat)}- ${shapeText}\n`;
}
node.children.forEach(child => {
layer++;
traverseMindMapTree(child, prefix, repeat);
layer--;
});
};
traverseMindMapTree(mindMapTree, options.prefix, options.repeat);
return mindMapContent;
}

View File

@@ -0,0 +1,20 @@
import { MindMapPlaceholder } from './toolbar/mindmap-importing-placeholder';
import { EdgelessMindmapMenu } from './toolbar/mindmap-menu';
import { EdgelessMindmapToolButton } from './toolbar/mindmap-tool-button';
export function effects() {
customElements.define(
'edgeless-mindmap-tool-button',
EdgelessMindmapToolButton
);
customElements.define('edgeless-mindmap-menu', EdgelessMindmapMenu);
customElements.define('mindmap-import-placeholder', MindMapPlaceholder);
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-mindmap-tool-button': EdgelessMindmapToolButton;
'edgeless-mindmap-menu': EdgelessMindmapMenu;
'mindmap-import-placeholder': MindMapPlaceholder;
}
}

View File

@@ -0,0 +1,8 @@
export * from './adapter';
export * from './indicator-overlay';
export * from './interactivity';
export * from './renderer';
export * from './toolbar/config';
export * from './toolbar/senior-tool';
export * from './utils';
export * from './view/index';

View File

@@ -0,0 +1,301 @@
import { Overlay } from '@blocksuite/affine-block-surface';
import { PathGenerator } from '@blocksuite/affine-gfx-connector';
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';
import { NODE_HORIZONTAL_SPACING, NODE_VERTICAL_SPACING } from '../view/layout';
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;
}
}

View File

@@ -0,0 +1,190 @@
import {
LayoutType,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
} from '@blocksuite/affine-model';
import { Bound } from '@blocksuite/global/gfx';
import last from 'lodash-es/last';
import { NODE_HORIZONTAL_SPACING, NODE_VERTICAL_SPACING } from '../view/layout';
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;
}
};

View File

@@ -0,0 +1 @@
export * from './mind-map-drag';

View File

@@ -0,0 +1,465 @@
import {
CanvasRenderer,
OverlayIdentifier,
type SurfaceBlockComponent,
} from '@blocksuite/affine-block-surface';
import {
type LayoutType,
type LocalConnectorElementModel,
MindmapElementModel,
type MindmapNode,
} from '@blocksuite/affine-model';
import type { Bound, IVec } from '@blocksuite/global/gfx';
import {
type DragExtensionInitializeContext,
type ExtensionDragEndContext,
type ExtensionDragMoveContext,
type ExtensionDragStartContext,
type GfxModel,
type GfxPrimitiveElementModel,
InteractivityExtension,
isGfxGroupCompatibleModel,
} from '@blocksuite/std/gfx';
import type { MindMapIndicatorOverlay } from '../indicator-overlay';
import { isMindmapNode, isSingleMindMapNode } from '../utils';
import { NODE_HORIZONTAL_SPACING, NODE_VERTICAL_SPACING } from '../view/layout';
import {
containsNode,
createFromTree,
detachMindmap,
findTargetNode,
hideNodeConnector,
tryMoveNode,
} from '../view/utils';
import { calculateResponseArea } from './drag-utils';
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 InteractivityExtension {
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
? 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 &&
!containsNode(hoveredMindMap, hoveredNode, dragMindMapCtx.node)
) {
const operation = 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 = 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) {
detachMindmap(dragMindMapCtx.mindmap, dragMindMapCtx.node);
const mindmap = 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 | null;
const renderer = surfaceBlock?.renderer;
const indicatorOverlay = this._indicatorOverlay;
// TODO: handle DOM renderer case for mindmap drag image
if (
!renderer ||
!(renderer instanceof CanvasRenderer) ||
!indicatorOverlay
) {
console.warn(
'Skipping drag image setup: DOM renderer or overlay missing.'
);
return () => {}; // Return an empty cleanup function
}
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 mounted() {
this.action.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 {};
});
}
}

View File

@@ -0,0 +1,65 @@
import { DomElementRendererExtension } from '@blocksuite/affine-block-surface';
import {
connectorBaseDomRenderer,
ConnectorPathGenerator,
} from '@blocksuite/affine-gfx-connector';
import type {
MindmapElementModel,
MindmapNode,
} from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
export const MindmapDomRendererExtension = DomElementRendererExtension(
'mindmap',
(model: MindmapElementModel, domElement, renderer) => {
const bound = model.elementBound;
const { zoom } = renderer.viewport;
// Set element size and position
domElement.style.width = `${bound.w * zoom}px`;
domElement.style.height = `${bound.h * zoom}px`;
domElement.style.overflow = 'visible';
domElement.style.pointerEvents = 'none';
const newChildren: HTMLDivElement[] = [];
const traverse = (node: MindmapNode) => {
const connectors = model.getConnectors(node);
if (!connectors) return;
connectors.reverse().forEach(result => {
const { connector, outdated } = result;
const elementGetter = (id: string) =>
model.surface.getElementById(id) ??
(model.surface.store.getModelById(id) as GfxModel);
if (outdated) {
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
}
const connectorContainer = document.createElement('div');
connectorContainer.style.position = 'absolute';
connectorContainer.style.transformOrigin = 'top left';
const geometricStyles = {
left: `${(connector.x - bound.x) * zoom}px`,
top: `${(connector.y - bound.y) * zoom}px`,
};
const opacityStyle = { opacity: node.element.opacity };
Object.assign(connectorContainer.style, geometricStyles, opacityStyle);
connectorBaseDomRenderer(connector, connectorContainer, renderer);
newChildren.push(connectorContainer);
});
if (node.detail.collapsed) {
return;
} else {
node.children.forEach(traverse);
}
};
model.tree && traverse(model.tree);
domElement.replaceChildren(...newChildren);
}
);

View File

@@ -0,0 +1,81 @@
import {
type ElementRenderer,
ElementRendererExtension,
} from '@blocksuite/affine-block-surface';
import {
connector as renderConnector,
ConnectorPathGenerator,
} from '@blocksuite/affine-gfx-connector';
import type {
MindmapElementModel,
MindmapNode,
} from '@blocksuite/affine-model';
import type { GfxModel } from '@blocksuite/std/gfx';
export const mindmap: ElementRenderer<MindmapElementModel> = (
model,
ctx,
matrix,
renderer,
rc,
bound
) => {
const dx = model.x - bound.x;
const dy = model.y - bound.y;
matrix = matrix.translate(-dx, -dy);
const mindmapOpacity = model.opacity;
const traverse = (node: MindmapNode) => {
const connectors = model.getConnectors(node);
if (!connectors) return;
connectors.reverse().forEach(result => {
const { connector, outdated } = result;
const elementGetter = (id: string) =>
model.surface.getElementById(id) ??
(model.surface.store.getModelById(id) as GfxModel);
if (outdated) {
ConnectorPathGenerator.updatePath(connector, null, elementGetter);
}
const dx = connector.x - bound.x;
const dy = connector.y - bound.y;
const origin = ctx.globalAlpha;
const shouldSetGlobalAlpha =
origin !== connector.opacity * mindmapOpacity;
if (shouldSetGlobalAlpha) {
ctx.globalAlpha = connector.opacity * mindmapOpacity;
}
renderConnector(
connector,
ctx,
matrix.translate(dx, dy),
renderer,
rc,
// NOTE: should we add this?
bound
);
if (shouldSetGlobalAlpha) {
ctx.globalAlpha = origin;
}
});
if (node.detail.collapsed) {
return;
} else {
node.children.forEach(traverse);
}
};
model.tree && traverse(model.tree);
};
export const MindmapElementRendererExtension = ElementRendererExtension(
'mindmap',
mindmap
);

View File

@@ -0,0 +1,2 @@
export * from './dom-renderer';
export * from './element-renderer';

View File

@@ -0,0 +1,19 @@
import {
type StoreExtensionContext,
StoreExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import {
mindmapToMarkdownAdapterMatcher,
mindmapToPlainTextAdapterMatcher,
} from './adapter';
export class MindmapStoreExtension extends StoreExtensionProvider {
override name = 'affine-mindmap-gfx';
override setup(context: StoreExtensionContext) {
super.setup(context);
context.register(mindmapToPlainTextAdapterMatcher);
context.register(mindmapToMarkdownAdapterMatcher);
}
}

View File

@@ -0,0 +1,46 @@
import { ColorScheme, MindmapStyle } from '@blocksuite/affine-model';
import type { TemplateResult } from 'lit';
import { type DraggableTool, getMindmapRender } from './basket-elements.js';
import {
mindMapStyle1Dark,
mindMapStyle1Light,
mindMapStyle2Dark,
mindMapStyle2Light,
mindMapStyle3,
mindMapStyle4,
} from './icons.js';
export type ToolbarMindmapItem = {
type: 'mindmap';
icon: TemplateResult;
style: MindmapStyle;
render: DraggableTool['render'];
};
export const getMindMaps = (theme: ColorScheme): ToolbarMindmapItem[] => [
{
type: 'mindmap',
icon: theme === ColorScheme.Dark ? mindMapStyle1Dark : mindMapStyle1Light,
style: MindmapStyle.ONE,
render: getMindmapRender(MindmapStyle.ONE),
},
{
type: 'mindmap',
icon: mindMapStyle4,
style: MindmapStyle.FOUR,
render: getMindmapRender(MindmapStyle.FOUR),
},
{
type: 'mindmap',
icon: mindMapStyle3,
style: MindmapStyle.THREE,
render: getMindmapRender(MindmapStyle.THREE),
},
{
type: 'mindmap',
icon: theme === 'light' ? mindMapStyle2Light : mindMapStyle2Dark,
style: MindmapStyle.TWO,
render: getMindmapRender(MindmapStyle.TWO),
},
];

View File

@@ -0,0 +1,206 @@
import { addAttachments } from '@blocksuite/affine-block-attachment';
import { addImages } from '@blocksuite/affine-block-image';
import {
CanvasElementType,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import {
insertEdgelessTextCommand,
mountTextElementEditor,
} from '@blocksuite/affine-gfx-text';
import {
MAX_IMAGE_WIDTH,
type MindmapStyle,
TextElementModel,
} from '@blocksuite/affine-model';
import {
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import { openSingleFileWith } from '@blocksuite/affine-shared/utils';
import { Bound, type IVec } from '@blocksuite/global/gfx';
import type { BlockComponent } from '@blocksuite/std';
import type { TemplateResult } from 'lit';
import * as Y from 'yjs';
export type ConfigProperty = 'x' | 'y' | 'r' | 's' | 'z' | 'o';
export type ConfigState = 'default' | 'active' | 'hover' | 'next';
export type ConfigStyle = Partial<Record<ConfigProperty, number | string>>;
export type ToolConfig = Record<ConfigState, ConfigStyle>;
export type DraggableTool = {
name: 'text' | 'mindmap' | 'media';
icon: TemplateResult;
config: ToolConfig;
standardWidth?: number;
render: (bound: Bound, edgeless: BlockComponent) => Promise<string | null>;
};
const unitMap = { x: 'px', y: 'px', r: 'deg', s: '', z: '', o: '' };
export const textConfig: ToolConfig = {
default: { x: -20, y: -8, r: 7.74, s: 0.92, z: 3 },
active: { x: -22, y: -9, r: -8, s: 0.92 },
hover: { x: -22, y: -9, r: -8, s: 1, z: 3 },
next: { x: -22, y: 64, r: 0 },
};
export const mindmapConfig: ToolConfig = {
default: { x: 4, y: -4, s: 1, r: -7, z: 2 },
active: { x: 11, y: -14, r: 9, s: 1 },
hover: { x: 11, y: -14, r: 9, s: 1.16, z: 3 },
next: { y: 64, r: 0 },
};
export const mediaConfig: ToolConfig = {
default: { x: -20, y: -15, r: 23, s: 1.2, z: 1 },
active: { x: -25, y: -20, r: -9, s: 1.2 },
hover: { x: -25, y: -20, r: -9, s: 1.5, z: 3 },
next: { y: 64, r: 0 },
};
export const getMindmapRender =
(mindmapStyle: MindmapStyle): DraggableTool['render'] =>
async (bound, edgeless) => {
const [x, y, _, h] = bound.toXYWH();
const rootW = 145;
const rootH = 50;
const nodeW = 80;
const nodeH = 35;
const centerVertical = y + h / 2;
const rootX = x;
const rootY = centerVertical - rootH / 2;
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh: string;
};
const root: MindMapNode = {
children: [],
text: 'Mind Map',
xywh: `[${rootX},${rootY},${rootW},${rootH}]`,
};
for (let i = 0; i < 3; i++) {
const nodeX = x + rootW + 300;
const nodeY = centerVertical - nodeH / 2 + (i - 1) * 50;
root.children.push({
children: [],
text: 'Text',
xywh: `[${nodeX},${nodeY},${nodeW},${nodeH}]`,
});
}
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
const mindmapId = crud.addElement('mindmap', {
style: mindmapStyle,
children: root,
}) as string;
edgeless.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'toolbar:dnd', // for now we use toolbar:dnd for all mindmap creation here
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'mindmap',
});
return mindmapId;
};
export const textRender: DraggableTool['render'] = async (bound, edgeless) => {
const vCenter = bound.y + bound.h / 2;
const w = 100;
const h = 32;
const flag = edgeless.store
.get(FeatureFlagService)
.getFlag('enable_edgeless_text');
let id: string;
if (flag) {
const [_, { textId }] = edgeless.std.command.exec(
insertEdgelessTextCommand,
{
x: bound.x,
y: vCenter - h / 2,
}
);
id = textId!;
} else {
const crud = edgeless.std.get(EdgelessCRUDIdentifier);
id = crud.addElement(CanvasElementType.TEXT, {
xywh: new Bound(bound.x, vCenter - h / 2, w, h).serialize(),
text: new Y.Text(),
}) as string;
edgeless.store.captureSync();
const textElement = crud.getElementById(id);
if (!(textElement instanceof TextElementModel)) {
console.error('Cannot mount text editor on a non-text element');
return null;
}
mountTextElementEditor(textElement, edgeless);
}
edgeless.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
control: 'toolbar:dnd',
page: 'whiteboard editor',
module: 'toolbar',
segment: 'toolbar',
type: 'text',
});
return id;
};
export const mediaRender: DraggableTool['render'] = async (bound, edgeless) => {
let file: File | null = null;
try {
file = await openSingleFileWith();
} catch (e) {
console.error(e);
return null;
}
if (!file) return null;
const files = [file];
const std = edgeless.std;
const point: IVec = [bound.x, bound.y];
// image
if (file.type.startsWith('image/')) {
const [id] = await addImages(std, files, {
point,
maxWidth: MAX_IMAGE_WIDTH,
shouldTransformPoint: false,
});
return id;
}
// attachment
const [id] = await addAttachments(std, files, point, false);
return id;
};
const toolStyle2StyleObj = (state: ConfigState, style: ConfigStyle = {}) => {
const styleObj = {} as Record<string, string>;
for (const [key, value] of Object.entries(style)) {
styleObj[`--${state}-${key}`] = `${value}${unitMap[key as ConfigProperty]}`;
}
return styleObj;
};
export const toolConfig2StyleObj = (config: ToolConfig) => {
const styleObj = {} as Record<string, string>;
for (const [state, style] of Object.entries(config)) {
Object.assign(
styleObj,
toolStyle2StyleObj(state as ConfigState, {
...config.default,
...style,
})
);
}
return styleObj;
};

View File

@@ -0,0 +1,199 @@
import { hasGrouped } from '@blocksuite/affine-gfx-shape';
import {
LayoutType,
MindmapElementModel,
MindmapStyle,
ShapeElementModel,
} from '@blocksuite/affine-model';
import {
type ToolbarContext,
type ToolbarModuleConfig,
ToolbarModuleExtension,
} from '@blocksuite/affine-shared/services';
import { getMostCommonValue } from '@blocksuite/affine-shared/utils';
import {
type MenuItem,
renderMenu,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { RadiantIcon, RightLayoutIcon, StyleIcon } from '@blocksuite/icons/lit';
import { BlockFlavourIdentifier } from '@blocksuite/std';
import {
MindmapStyleFour,
MindmapStyleOne,
MindmapStyleThree,
MindmapStyleTwo,
} from '../view/index';
const MINDMAP_STYLE_LIST = [
{
value: MindmapStyle.ONE,
icon: MindmapStyleOne,
},
{
value: MindmapStyle.FOUR,
icon: MindmapStyleFour,
},
{
value: MindmapStyle.THREE,
icon: MindmapStyleThree,
},
{
value: MindmapStyle.TWO,
icon: MindmapStyleTwo,
},
] as const satisfies MenuItem<MindmapStyle>[];
const MINDMAP_LAYOUT_LIST = [
{
key: 'Left',
value: LayoutType.LEFT,
icon: RightLayoutIcon({
style: 'transform: rotate(0.5turn); transform-origin: center;',
}),
},
{
key: 'Radial',
value: LayoutType.BALANCE,
icon: RadiantIcon(),
},
{
key: 'Right',
value: LayoutType.RIGHT,
icon: RightLayoutIcon(),
},
] as const satisfies MenuItem<LayoutType>[];
export const createMindmapStyleActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const style = getMostCommonValue(models, 'style') ?? MindmapStyle.ONE;
const onPick = (style: MindmapStyle) => {
for (const model of models) {
model.style = style;
}
ctx.settings.recordLastProps('mindmap', { style });
};
return renderMenu({
label: 'Style',
icon: StyleIcon(),
items: MINDMAP_STYLE_LIST,
currentValue: style,
onPick,
});
};
export const createMindmapLayoutActionMenu = (
ctx: ToolbarContext,
models: MindmapElementModel[]
) => {
const layoutType =
getMostCommonValue(models, 'layoutType') ?? LayoutType.BALANCE;
const onPick = (layoutType: LayoutType) => {
for (const model of models) {
model.layoutType = layoutType;
model.layout();
}
ctx.settings.recordLastProps('mindmap', { layoutType });
};
return renderMenu({
label: 'Layout',
items: MINDMAP_LAYOUT_LIST,
currentValue: layoutType,
onPick,
});
};
export const mindmapToolbarConfig = {
actions: [
{
id: 'a.style',
content(ctx) {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
return createMindmapStyleActionMenu(ctx, models);
},
},
{
id: 'b.layout',
content(ctx) {
const models = ctx.getSurfaceModelsByType(MindmapElementModel);
if (!models.length) return null;
return createMindmapLayoutActionMenu(ctx, models);
},
},
],
when: ctx => ctx.getSurfaceModelsByType(MindmapElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;
export const shapeMindmapToolbarConfig = {
actions: [
{
id: 'a.mindmap-style',
when(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
return models.some(hasGrouped);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
if (!models.length) return null;
let mindmaps = models
.map(model => model.group)
.filter(model => ctx.matchModel(model, MindmapElementModel));
if (!mindmaps.length) return null;
// Not displayed when there is both a normal shape and a mindmap shape.
if (models.length !== mindmaps.length) return null;
mindmaps = Array.from(new Set(mindmaps));
return createMindmapStyleActionMenu(ctx, mindmaps);
},
},
{
id: 'b.mindmap-layout',
when(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
return models.some(hasGrouped);
},
content(ctx) {
const models = ctx.getSurfaceModelsByType(ShapeElementModel);
if (!models.length) return null;
let mindmaps = models
.map(model => model.group)
.filter(model => ctx.matchModel(model, MindmapElementModel));
if (!mindmaps.length) return null;
// Not displayed when there is both a normal shape and a mindmap shape.
if (models.length !== mindmaps.length) return null;
mindmaps = Array.from(new Set(mindmaps));
// It's a sub node.
if (models.length === 1 && mindmaps[0].tree.element !== models[0])
return null;
return createMindmapLayoutActionMenu(ctx, mindmaps);
},
},
],
when: ctx => ctx.getSurfaceModelsByType(ShapeElementModel).length > 0,
} as const satisfies ToolbarModuleConfig;
export const mindmapToolbarExtension = ToolbarModuleExtension({
id: BlockFlavourIdentifier('affine:surface:mindmap'),
config: mindmapToolbarConfig,
});
export const shapeMindmapToolbarExtension = ToolbarModuleExtension({
id: BlockFlavourIdentifier('custom:affine:surface:shape'),
config: shapeMindmapToolbarConfig,
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,54 @@
import { LoadingIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { ShadowlessElement } from '@blocksuite/std';
import { css, html } from 'lit';
import { importMindMapIcon } from './icons.js';
export class MindMapPlaceholder extends ShadowlessElement {
static override styles = css`
mindmap-import-placeholder {
display: flex;
flex-direction: column;
padding: 28px 12px 12px;
box-sizing: border-box;
width: 200px;
height: 122px;
border-radius: 12px;
gap: 12px;
background-color: ${unsafeCSSVarV2('layer/background/secondary')};
border: 1px solid ${unsafeCSSVarV2('layer/insideBorder/border')};
color: ${unsafeCSSVarV2('text/placeholder')};
box-shadow: 0px 0px 4px 0px rgba(66, 65, 73, 0.14);
}
mindmap-import-placeholder .preview-icon {
text-align: center;
}
mindmap-import-placeholder .description {
display: flex;
gap: 8px;
color: ${unsafeCSSVarV2('text/placeholder')};
font-size: 14px;
line-height: 22px;
align-items: center;
}
`;
override render() {
return html`<div class="placeholder-container">
<div class="preview-icon">${importMindMapIcon}</div>
<div class="description">
${LoadingIcon()}
<span>Importing mind map...</span>
</div>
</div>`;
}
}

View File

@@ -0,0 +1,423 @@
import { DefaultTool } from '@blocksuite/affine-block-surface';
import { toast } from '@blocksuite/affine-components/toast';
import { EmptyTool } from '@blocksuite/affine-gfx-pointer';
import type { MindmapStyle } from '@blocksuite/affine-model';
import {
EditPropsStore,
FeatureFlagService,
TelemetryProvider,
} from '@blocksuite/affine-shared/services';
import {
EdgelessDraggableElementController,
EdgelessToolbarToolMixin,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import { ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import type { BlockStdScope } from '@blocksuite/std';
import { modelContext, stdContext } from '@blocksuite/std';
import type { BlockModel } from '@blocksuite/store';
import { consume } from '@lit/context';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
import { property } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { getMindMaps, type ToolbarMindmapItem } from './assets.js';
import { mediaRender, textRender } from './basket-elements.js';
import { importMindMapIcon, mindmapMenuMediaIcon, textIcon } from './icons.js';
import { MindMapPlaceholder } from './mindmap-importing-placeholder.js';
type TextItem = {
type: 'text';
icon: TemplateResult;
render: typeof textRender;
};
type MediaItem = {
type: 'media';
icon: TemplateResult;
render: typeof mediaRender;
};
type ImportItem = {
type: 'import';
icon: TemplateResult;
};
const textItem: TextItem = { type: 'text', icon: textIcon, render: textRender };
const mediaItem: MediaItem = {
type: 'media',
icon: mindmapMenuMediaIcon,
render: mediaRender,
};
export class EdgelessMindmapMenu extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
display: flex;
z-index: -1;
justify-content: flex-end;
}
.text-and-mindmap {
display: flex;
gap: 10px;
padding: 8px 0px;
box-sizing: border-box;
}
.thin-divider {
width: 1px;
transform: scaleX(0.5);
height: 48px;
background: var(--affine-border-color);
}
.text-item,
.media-item {
width: 60px;
}
.mindmap-item {
width: 64px;
}
.text-item,
.media-item,
.mindmap-item {
border-radius: 4px;
height: 48px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.text-item > button,
.media-item > button,
.mindmap-item > button {
position: absolute;
border-radius: inherit;
border: none;
background: none;
cursor: grab;
padding: 0;
}
.text-item:hover,
.media-item:hover,
.mindmap-item[data-is-active='true'],
.mindmap-item:hover {
background: var(--affine-hover-color);
}
.text-item > button.next,
.media-item > button.next,
.mindmap-item > button.next {
transition: transform 0.3s ease-in-out;
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<
ToolbarMindmapItem | TextItem | ImportItem | MediaItem
>;
override type = EmptyTool;
get mindMaps() {
return getMindMaps(this.theme);
}
private _importMindMapEntry() {
const { draggingElement } = this.draggableController?.states || {};
const isBeingDragged = draggingElement?.data.type === 'import';
return html`<div class="mindmap-item">
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
class="next"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: importMindMapIcon,
data: {
type: 'import',
icon: importMindMapIcon,
},
standardWidth: 350,
});
}}
@click=${() => {
this.draggableController.cancel();
const viewportBound = this.gfx.viewport.viewportBounds;
viewportBound.x += viewportBound.w / 2;
viewportBound.y += viewportBound.h / 2;
this._onImportMindMap(viewportBound);
}}
>
${importMindMapIcon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Support import of FreeMind,OPML.'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>`;
}
private _onImportMindMap(bound: Bound) {
const placeholder = new MindMapPlaceholder();
placeholder.style.position = 'absolute';
placeholder.style.left = `${bound.x}px`;
placeholder.style.top = `${bound.y}px`;
this.gfx.viewport.element?.append(placeholder);
this.onImportMindMap?.(bound)
.then(() => {
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'success',
module: 'toolbar',
});
})
.catch(e => {
if (e.code === ErrorCode.UserAbortError) return;
this.std.getOptional(TelemetryProvider)?.track('CanvasElementAdded', {
page: 'whiteboard editor',
type: 'imported mind map',
other: 'failed',
module: 'toolbar',
});
toast(this.edgeless.host, 'Import failed, please try again');
console.error(e);
})
.finally(() => {
placeholder.remove();
});
}
initDragController() {
if (this.draggableController || !this.edgeless) return;
this.draggableController = new EdgelessDraggableElementController(this, {
edgeless: this.edgeless,
scopeElement: this,
clickToDrag: true,
onOverlayCreated: (_layer, element) => {
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
}
// a workaround to active mindmap, so that menu cannot be closed by `Escape`
this.setEdgelessTool(EmptyTool);
},
onDrop: (element, bound) => {
if ('render' in element.data) {
element.data
.render(bound, this.edgeless)
.then(id => {
if (!id) return;
if (element.data.type === 'mindmap') {
this.onActiveStyleChange?.(element.data.style);
this.setEdgelessTool(DefaultTool);
this.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (
element.data.type === 'text' ||
element.data.type === 'media'
) {
this.setEdgelessTool(DefaultTool);
}
})
.catch(console.error);
} else if (element.data.type === 'import') {
this._onImportMindMap?.(bound);
}
},
});
this.edgeless.bindHotKey(
{
m: () => {
const gfx = this.gfx;
const locked = gfx.viewport.locked;
if (locked) return;
if (gfx.selection.editing) return;
// toolbar mindmap button will capture the `m` key and create a new overlay
this.draggableController.cancelWithoutAnimation();
},
},
{ global: true }
);
}
override render() {
const { cancelled, draggingElement, dragOut } =
this.draggableController?.states || {};
const isDraggingMedia = draggingElement?.data?.type === 'media';
const isDraggingText = draggingElement?.data?.type === 'text';
const showNextText = dragOut && !cancelled;
return html`<edgeless-slide-menu .height=${'64px'}>
<div class="text-and-mindmap">
<div class="media-item">
${
isDraggingMedia
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${mediaItem.icon}
</button>`
: nothing
}
<button
style="opacity: ${isDraggingMedia ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: mediaItem.icon,
data: mediaItem,
})}
>
${mediaItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Add media'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<div class="text-item">
${
isDraggingText
? html`<button
class="next"
style="transform: translateY(${showNextText ? 0 : 64}px)"
>
${textItem.icon}
</button>`
: nothing
}
<button
style="opacity: ${isDraggingText ? 0 : 1}"
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
preview: textItem.icon,
data: textItem,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
preview: textItem.icon,
data: textItem,
})}
>
${textItem.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Edgeless Text'}"
data-shortcup="${'T'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
<div class="thin-divider"></div>
<!-- mind map -->
${repeat(this.mindMaps, mindMap => {
const isDraggingMindMap = draggingElement?.data?.type !== 'text';
const draggingEle = draggingElement?.data as ToolbarMindmapItem;
const isBeingDragged =
isDraggingMindMap && draggingEle?.style === mindMap.style;
const showNext = dragOut && !cancelled;
const isActive = this._style$.value === mindMap.style;
return html`
<div class="mindmap-item" data-is-active=${isActive}>
${
isBeingDragged
? html`<button
style="transform: translateY(${showNext ? 0 : 64}px)"
class="next"
>
${mindMap.icon}
</button>`
: nothing
}
<button
style="opacity: ${isBeingDragged ? 0 : 1}"
@mousedown=${(e: MouseEvent) => {
this.draggableController.onMouseDown(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@touchstart=${(e: TouchEvent) => {
this.draggableController.onTouchStart(e, {
preview: mindMap.icon,
data: mindMap,
standardWidth: 350,
});
}}
@click=${() => this.onActiveStyleChange?.(mindMap.style)}
>
${mindMap.icon}
</button>
<affine-tooltip tip-position="top" .offset=${12}>
<affine-tooltip-content-with-shortcut
data-tip="${'Mind Map'}"
data-shortcup="${'M'}"
></affine-tooltip-content-with-shortcut>
</affine-tooltip>
</div>
`;
})}
${
this.std.store
.get(FeatureFlagService)
.getFlag('enable_mind_map_import')
? this._importMindMapEntry()
: nothing
}
</div>
</edgeless-slide-menu>`;
}
override updated(changedProperties: Map<PropertyKey, unknown>) {
if (!changedProperties.has('edgeless')) return;
this.initDragController();
}
@consume({ context: modelContext })
accessor model!: BlockModel;
@property({ attribute: false })
accessor onActiveStyleChange!: (style: MindmapStyle) => void;
@property({ attribute: false })
accessor onImportMindMap!: (bound: Bound) => Promise<void>;
@consume({ context: stdContext })
accessor std!: BlockStdScope;
}

View File

@@ -0,0 +1,468 @@
import {
DefaultTool,
EdgelessCRUDIdentifier,
} from '@blocksuite/affine-block-surface';
import { EmptyTool } from '@blocksuite/affine-gfx-pointer';
import { TextTool } from '@blocksuite/affine-gfx-text';
import type {
MindmapElementModel,
MindmapStyle,
} from '@blocksuite/affine-model';
import {
EditPropsStore,
ThemeProvider,
ViewportElementProvider,
} from '@blocksuite/affine-shared/services';
import {
EdgelessDraggableElementController,
EdgelessToolbarToolMixin,
} from '@blocksuite/affine-widget-edgeless-toolbar';
import type { Bound } from '@blocksuite/global/gfx';
import { SignalWatcher } from '@blocksuite/global/lit';
import { computed } from '@preact/signals-core';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { repeat } from 'lit/directives/repeat.js';
import { styleMap } from 'lit/directives/style-map.js';
import { getMindMaps } from './assets.js';
import {
type DraggableTool,
getMindmapRender,
mediaConfig,
mediaRender,
mindmapConfig,
textConfig,
textRender,
toolConfig2StyleObj,
} from './basket-elements.js';
import {
basketIconDark,
basketIconLight,
mindmapMenuMediaIcon,
textIcon,
} from './icons.js';
import { importMindmap } from './utils/import-mindmap.js';
export class EdgelessMindmapToolButton extends EdgelessToolbarToolMixin(
SignalWatcher(LitElement)
) {
static override styles = css`
:host {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.partial-clip {
flex-shrink: 0;
box-sizing: border-box;
width: calc(100% + 20px);
pointer-events: none;
padding: 0 10px;
overflow: hidden;
}
.basket-wrapper {
pointer-events: auto;
height: 64px;
width: 96px;
display: flex;
justify-content: center;
align-items: flex-end;
position: relative;
}
.basket,
.basket-tool-item {
transition: transform 0.3s ease-in-out;
position: absolute;
}
.basket {
bottom: 0;
height: 17px;
width: 76px;
}
.basket > div,
.basket > svg {
position: absolute;
}
.glass {
width: 76px;
height: 17px;
border-radius: 2px;
mask: url(#mindmap-basket-body-mask);
}
.glass.enabled {
backdrop-filter: blur(2px);
}
@-moz-document url-prefix() {
.glass.enabled {
backdrop-filter: none;
}
}
.basket {
z-index: 3;
}
.basket-tool-item {
cursor: grab;
}
.basket-tool-item svg {
display: block;
}
.basket-tool-item {
transform: translate(var(--default-x, 0), var(--default-y, 0))
rotate(var(--default-r, 0)) scale(var(--default-s, 1));
z-index: var(--default-z, 0);
}
.basket-tool-item.next {
transform: translate(var(--next-x, 0), var(--next-y, 0))
rotate(var(--next-r, 0)) scale(var(--next-s, 1));
z-index: var(--next-z, 0);
}
/* active & hover */
.basket-wrapper:hover .basket,
.basket-wrapper.active .basket {
z-index: 0;
}
.basket-wrapper:hover .basket-tool-item.current,
.basket-wrapper.active .basket-tool-item.current {
transform: translate(var(--active-x, 0), var(--active-y, 0))
rotate(var(--active-r, 0)) scale(var(--active-s, 1));
z-index: var(--active-z, 0);
}
.basket-tool-item.next.coming,
.basket-wrapper:hover .basket-tool-item.current:hover {
transform: translate(var(--hover-x, 0), var(--hover-y, 0))
rotate(var(--hover-r, 0)) scale(var(--hover-s, 1));
z-index: var(--hover-z, 0);
}
`;
private readonly _style$ = computed(() => {
const { style } =
this.edgeless.std.get(EditPropsStore).lastProps$.value.mindmap;
return style;
});
draggableController!: EdgelessDraggableElementController<DraggableTool>;
override enableActiveBackground = true;
override type = [EmptyTool, TextTool];
get draggableTools(): DraggableTool[] {
const style = this._style$.value;
const mindmap =
this.mindmaps.find(m => m.style === style) || this.mindmaps[0];
return [
{
name: 'media',
icon: mindmapMenuMediaIcon,
config: mediaConfig,
standardWidth: 100,
render: mediaRender,
},
{
name: 'text',
icon: textIcon,
config: textConfig,
standardWidth: 100,
render: textRender,
},
{
name: 'mindmap',
icon: mindmap.icon,
config: mindmapConfig,
standardWidth: 350,
render: getMindmapRender(style),
},
];
}
get mindmaps() {
return getMindMaps(this.theme);
}
get crud() {
return this.edgeless.std.get(EdgelessCRUDIdentifier);
}
private _toggleMenu() {
if (this.tryDisposePopper()) return;
this.setEdgelessTool(DefaultTool);
const menu = this.createPopper('edgeless-mindmap-menu', this);
Object.assign(menu.element, {
edgeless: this.edgeless,
onActiveStyleChange: (style: MindmapStyle) => {
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style,
});
},
onImportMindMap: (bound: Bound) => {
return importMindmap(bound).then(mindmap => {
const id = this.crud.addElement('mindmap', {
children: mindmap,
layoutType: mindmap?.layoutType === 'left' ? 1 : 0,
});
if (!id) return;
const element = this.crud.getElementById(id) as MindmapElementModel;
this.tryDisposePopper();
this.setEdgelessTool(DefaultTool);
this.gfx.selection.set({
elements: [element.tree.id],
editing: false,
});
});
},
});
}
initDragController() {
if (!this.edgeless || !this.toolbarContainer) return;
if (this.draggableController) return;
this.draggableController = new EdgelessDraggableElementController(this, {
edgeless: this.edgeless,
scopeElement: this.toolbarContainer,
standardWidth: 100,
clickToDrag: false,
onOverlayCreated: (overlay, { data }) => {
const tool = this.draggableTools.find(t => t.name === data.name);
if (!tool) return;
// recover the rotation
const rotate = tool.config?.hover?.r ?? tool.config?.default?.r ?? 0;
overlay.element.style.setProperty('--rotate', rotate + 'deg');
setTimeout(() => {
overlay.transitionWrapper.style.setProperty(
'--rotate',
-rotate + 'deg'
);
}, 50);
// set the scale (without transition)
const scale = tool.config?.hover?.s ?? tool.config?.default?.s ?? 1;
overlay.element.style.setProperty('--scale', `${scale}`);
// a workaround to handle getBoundingClientRect() when the element is rotated
const _left = parseInt(overlay.element.style.left);
const _top = parseInt(overlay.element.style.top);
if (data.name === 'mindmap') {
overlay.element.style.left = _left + 3 + 'px';
overlay.element.style.top = _top + 5 + 'px';
} else if (data.name === 'text') {
overlay.element.style.left = _left + 0 + 'px';
overlay.element.style.top = _top + 3 + 'px';
}
this.readyToDrop = true;
},
onCanceled: overlay => {
overlay.transitionWrapper.style.transformOrigin = 'unset';
overlay.transitionWrapper.style.setProperty('--rotate', '0deg');
this.readyToDrop = false;
},
onDrop: (el, bound) => {
el.data
.render(bound, this.edgeless)
.then(id => {
if (!id) return;
this.readyToDrop = false;
if (el.data.name === 'mindmap') {
this.setEdgelessTool(DefaultTool);
this.gfx.selection.set({
elements: [id],
editing: false,
});
} else if (el.data.name === 'text') {
this.setEdgelessTool(DefaultTool);
}
})
.catch(console.error);
},
});
this.edgeless.bindHotKey(
{
m: () => {
const gfx = this.gfx;
const locked = gfx.viewport.locked;
if (locked) return;
if (gfx.selection.editing) return;
if (this.readyToDrop) {
// change the style
const activeIndex = this.mindmaps.findIndex(
m => m.style === this._style$.value
);
const nextIndex = (activeIndex + 1) % this.mindmaps.length;
const next = this.mindmaps[nextIndex];
this.edgeless.std.get(EditPropsStore).recordLastProps('mindmap', {
style: next.style,
});
const tool = this.draggableTools.find(t => t.name === 'mindmap');
this.draggableController.updateElementInfo({
data: tool,
preview: next.icon,
});
return;
}
this.setEdgelessTool(EmptyTool);
const icon = this.mindmapElement;
const { x, y } = gfx.tool.lastMouseViewPos$.peek();
const { viewport } = this.edgeless.std.get(ViewportElementProvider);
const { left, top } = viewport;
const clientPos = { x: x + left, y: y + top };
this.draggableController.dragAndMoveTo(icon, clientPos);
},
},
{ global: true }
);
// since there is not a tool called mindmap, we need to cancel the drag when the tool is changed
this.disposables.add(
this.gfx.tool.currentToolName$.subscribe(toolName => {
// FIXME: remove the assertion after gfx tool refactor
if ((toolName as string) !== 'empty' && this.readyToDrop) {
this.draggableController.cancel();
}
})
);
}
override render() {
const { popper } = this;
const appTheme = this.edgeless.std.get(ThemeProvider).app$.value;
const basketIcon = appTheme === 'light' ? basketIconLight : basketIconDark;
const glassBg =
appTheme === 'light' ? 'rgba(255,255,255,0.5)' : 'rgba(74, 74, 74, 0.6)';
const { cancelled, dragOut, draggingElement } =
this.draggableController?.states || {};
const active = popper || draggingElement;
return html`<edgeless-toolbar-button
class="edgeless-mindmap-button"
?withHover=${true}
.tooltip=${popper ? '' : 'Others'}
.tooltipOffset=${4}
@click=${this._toggleMenu}
style="width: 100%; height: 100%; display: inline-block"
>
<div class="partial-clip">
<div class="basket-wrapper ${active ? 'active' : ''}">
${repeat(
this.draggableTools,
t => t.name,
tool => {
const isBeingDragged = draggingElement?.data.name === tool.name;
const variables = toolConfig2StyleObj(tool.config);
const nextStyle = styleMap({
...variables,
});
const currentStyle = styleMap({
...variables,
opacity: isBeingDragged ? 0 : 1,
pointerEvents: draggingElement ? 'none' : 'auto',
});
return html`${
isBeingDragged
? html`<div
class=${classMap({
'basket-tool-item': true,
next: true,
coming: !!dragOut && !cancelled,
})}
style=${nextStyle}
>
${tool.icon}
</div>`
: nothing
}
<div
style=${currentStyle}
@mousedown=${(e: MouseEvent) =>
this.draggableController.onMouseDown(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
@touchstart=${(e: TouchEvent) =>
this.draggableController.onTouchStart(e, {
data: tool,
preview: tool.icon,
standardWidth: tool.standardWidth,
})}
class="basket-tool-item current ${tool.name}"
>
${tool.icon}
</div>`;
}
)}
<div class="basket">
<div
class="glass ${this.enableBlur ? 'enabled' : ''}"
style="background: ${glassBg}"
></div>
${basketIcon}
</div>
</div>
</div>
<svg width="0" height="0" style="opacity: 0; pointer-events: none">
<defs>
<mask id="mindmap-basket-body-mask">
<rect
x="2"
width="71.8"
y="2"
height="15"
rx="1.5"
ry="1.5"
fill="white"
/>
<rect
width="32"
height="6"
x="22"
y="5.9"
fill="black"
rx="3"
ry="3"
/>
</mask>
</defs>
</svg>
</edgeless-toolbar-button>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>) {
const controllerRequiredProps = ['edgeless', 'toolbarContainer'] as const;
if (
controllerRequiredProps.some(p => _changedProperties.has(p)) &&
!this.draggableController
) {
this.initDragController();
}
}
@property({ type: Boolean })
accessor enableBlur = true;
@query('.basket-tool-item.mindmap')
accessor mindmapElement!: HTMLElement;
@state()
accessor readyToDrop = false;
}

View File

@@ -0,0 +1,15 @@
import { SeniorToolExtension } from '@blocksuite/affine-widget-edgeless-toolbar';
import { html } from 'lit';
export const mindMapSeniorTool = SeniorToolExtension(
'mindMap',
({ block, toolbarContainer }) => {
return {
name: 'Mind Map',
content: html`<edgeless-mindmap-tool-button
.edgeless=${block}
.toolbarContainer=${toolbarContainer}
></edgeless-mindmap-tool-button>`,
};
}
);

View File

@@ -0,0 +1,143 @@
import { openSingleFileWith } from '@blocksuite/affine-shared/utils';
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type { Bound } from '@blocksuite/global/gfx';
import c from 'simple-xml-to-json';
type MindMapNode = {
children: MindMapNode[];
text: string;
xywh?: string;
title?: string;
layoutType?: 'left' | 'right';
};
export async function importMindmap(bound: Bound): Promise<MindMapNode> {
const file = await openSingleFileWith('MindMap');
if (!file) {
throw new BlockSuiteError(ErrorCode.UserAbortError, 'Aborted by user');
}
let result;
if (file.name.endsWith('.mm')) {
result = await parseMmFile(file);
} else if (file.name.endsWith('.opml') || file.name.endsWith('.xml')) {
result = await parseOPMLFile(file);
} else {
throw new BlockSuiteError(ErrorCode.ParsingError, 'Unsupported file type');
}
if (result) {
result.xywh = bound.serialize();
}
return result;
}
function readAsText(file: File) {
return file.text();
}
type RawMmNode = {
node?: {
TEXT: string;
POSITION: 'left' | 'right';
children?: RawMmNode[];
};
};
async function parseMmFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const map = parsed.map.children[0];
const traverse = (node: RawMmNode): MindMapNode | null => {
if (!node.node) {
return null;
}
return node.node.POSITION
? {
layoutType: node.node.POSITION,
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
}
: {
text: node.node.TEXT ?? 'MINDMAP',
children:
(node.node.children
?.map(traverse)
.filter(node => node) as MindMapNode[]) ?? [],
};
};
const result = traverse(map);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse mm file'
);
}
}
type RawOPMLOutline = {
outline: {
text: string;
children: RawOPMLOutline[];
};
};
async function parseOPMLFile(file: File): Promise<MindMapNode> {
const content = await readAsText(file);
try {
const parsed = c.convertXML(content);
const outline = parsed.opml?.children[1].body?.children?.[0];
const traverse = (node: RawOPMLOutline): MindMapNode | null => {
if (!node.outline?.text && !node.outline?.children) {
return null;
}
return {
text: node.outline?.text ?? 'MINDMAP',
children: node.outline.children
? (node.outline.children.map(traverse) as MindMapNode[])
: [],
};
};
const result = traverse(outline);
if (!result) {
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML file'
);
}
return result;
} catch (e) {
console.error(e);
throw new BlockSuiteError(
ErrorCode.ParsingError,
'Failed to parse OPML file'
);
}
}

View File

@@ -0,0 +1,70 @@
import { MindmapElementModel } from '@blocksuite/affine-model';
import type {
GfxBlockElementModel,
GfxModel,
Viewport,
} from '@blocksuite/std/gfx';
export function isMindmapNode(element: GfxBlockElementModel | GfxModel | null) {
return element?.group instanceof MindmapElementModel;
}
export function isSingleMindMapNode(els: GfxModel[]) {
return els.length === 1 && els[0].group instanceof MindmapElementModel;
}
export function isElementOutsideViewport(
viewport: Viewport,
element: GfxModel,
padding: [number, number] = [0, 0]
) {
const elementBound = element.elementBound;
padding[0] /= viewport.zoom;
padding[1] /= viewport.zoom;
elementBound.x -= padding[1];
elementBound.w += padding[1];
elementBound.y -= padding[0];
elementBound.h += padding[0];
return !viewport.viewportBounds.contains(elementBound);
}
export function getNearestTranslation(
viewport: Viewport,
element: GfxModel,
padding: [number, number] = [0, 0]
) {
const viewportBound = viewport.viewportBounds;
const elementBound = element.elementBound;
let dx = 0;
let dy = 0;
if (elementBound.x - padding[1] < viewportBound.x) {
dx = viewportBound.x - (elementBound.x - padding[1]);
} else if (
elementBound.x + elementBound.w + padding[1] >
viewportBound.x + viewportBound.w
) {
dx =
viewportBound.x +
viewportBound.w -
(elementBound.x + elementBound.w + padding[1]);
}
if (elementBound.y - padding[0] < viewportBound.y) {
dy = elementBound.y - padding[0] - viewportBound.y;
} else if (
elementBound.y + elementBound.h + padding[0] >
viewportBound.y + viewportBound.h
) {
dy =
elementBound.y +
elementBound.h +
padding[0] -
(viewportBound.y + viewportBound.h);
}
return [dx, dy];
}

View File

@@ -0,0 +1,40 @@
import {
type ViewExtensionContext,
ViewExtensionProvider,
} from '@blocksuite/affine-ext-loader';
import { effects } from './effects';
import { MindMapIndicatorOverlay } from './indicator-overlay';
import { MindMapDragExtension } from './interactivity';
import {
MindmapDomRendererExtension,
MindmapElementRendererExtension,
} from './renderer';
import {
mindmapToolbarExtension,
shapeMindmapToolbarExtension,
} from './toolbar/config';
import { mindMapSeniorTool } from './toolbar/senior-tool';
import { MindMapInteraction, MindMapView } from './view/view';
export class MindmapViewExtension extends ViewExtensionProvider {
override name = 'affine-mindmap-gfx';
override effect(): void {
super.effect();
effects();
}
override setup(context: ViewExtensionContext) {
super.setup(context);
context.register(MindmapElementRendererExtension);
context.register(MindmapDomRendererExtension);
context.register(mindMapSeniorTool);
context.register(mindmapToolbarExtension);
context.register(shapeMindmapToolbarExtension);
context.register(MindMapView);
context.register(MindMapDragExtension);
context.register(MindMapIndicatorOverlay);
context.register(MindMapInteraction);
}
}

View File

@@ -0,0 +1,4 @@
export * from './layout';
export * from './style-svg';
export * from './utils';
export * from './view';

View File

@@ -0,0 +1,201 @@
import {
LayoutType,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
} from '@blocksuite/affine-model';
import type { SerializedXYWH } from '@blocksuite/global/gfx';
import { Bound } from '@blocksuite/global/gfx';
export const NODE_VERTICAL_SPACING = 45;
export const NODE_HORIZONTAL_SPACING = 110;
export const NODE_FIRST_LEVEL_HORIZONTAL_SPACING = 200;
type TreeSize = {
/**
* The parent of the tree
*/
parent: TreeSize | null;
/**
* The root node of the tree
*/
root: MindmapNode;
/**
* The size of the tree, including its descendants
*/
bound: Bound;
/**
* The size of the children of the root
*/
children: TreeSize[];
};
const calculateNodeSize = (
root: MindmapNode,
parent: TreeSize | null = null,
rootChildren?: MindmapNode[]
): TreeSize => {
const bound = root.element.elementBound;
const children: TreeSize[] = [];
const firstLevel = parent === null;
rootChildren = rootChildren ?? root.children;
const treeSize: TreeSize = {
parent,
root,
bound,
children,
};
if (rootChildren?.length && !root.detail.collapsed) {
const childrenBound = rootChildren.reduce(
(pre, node) => {
const childSize = calculateNodeSize(node, treeSize);
children.push(childSize);
pre.w = Math.max(pre.w, childSize.bound.w);
pre.h +=
pre.h > 0
? NODE_VERTICAL_SPACING + childSize.bound.h
: childSize.bound.h;
return pre;
},
new Bound(0, 0, 0, 0)
);
bound.w +=
childrenBound.w +
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING);
bound.h = Math.max(bound.h, childrenBound.h);
}
return treeSize;
};
const layoutTree = (
tree: TreeSize,
layoutType: LayoutType.LEFT | LayoutType.RIGHT,
mindmap: MindmapElementModel,
path: number[] = [0]
) => {
const firstLevel = path.length === 1;
const treeHeight = tree.bound.h;
const currentX =
layoutType === LayoutType.RIGHT
? tree.root.element.x +
tree.root.element.w +
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING)
: tree.root.element.x -
(firstLevel
? NODE_FIRST_LEVEL_HORIZONTAL_SPACING
: NODE_HORIZONTAL_SPACING);
let currentY = tree.root.element.y + (tree.root.element.h - treeHeight) / 2;
if (tree.root.element.h >= treeHeight && tree.children.length) {
const onlyChild = tree.children[0];
currentY += (tree.root.element.h - onlyChild.root.element.h) / 2;
}
if (!tree.root.detail.collapsed) {
tree.children.forEach((subtree, idx) => {
const subtreeRootEl = subtree.root.element;
const subtreeHeight = subtree.bound.h;
const xywh = `[${
layoutType === LayoutType.RIGHT ? currentX : currentX - subtreeRootEl.w
},${currentY + (subtreeHeight - subtreeRootEl.h) / 2},${subtreeRootEl.w},${subtreeRootEl.h}]` as SerializedXYWH;
const currentNodePath = [...path, idx];
if (subtreeRootEl.xywh !== xywh) {
subtreeRootEl.xywh = xywh;
}
layoutTree(subtree, layoutType, mindmap, currentNodePath);
currentY += subtreeHeight + NODE_VERTICAL_SPACING;
});
}
};
const layoutRight = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
layoutTree(rootTree, LayoutType.RIGHT, mindmap, path);
};
const layoutLeft = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
layoutTree(rootTree, LayoutType.LEFT, mindmap, path);
};
const layoutBalance = (
root: MindmapNode,
mindmap: MindmapElementModel,
path = [0]
) => {
const rootTree = calculateNodeSize(root, null);
const leftTree: MindmapNode[] = (root as MindmapRoot).left;
const rightTree: MindmapNode[] = (root as MindmapRoot).right;
{
const leftTreeSize = calculateNodeSize(root, null, leftTree);
const mockRoot = {
parent: null,
root: rootTree.root,
bound: leftTreeSize.bound,
children: leftTreeSize.children,
};
layoutTree(mockRoot, LayoutType.LEFT, mindmap, path);
}
{
const rightTreeSize = calculateNodeSize(root, null, rightTree);
const mockRoot = {
parent: null,
root: rootTree.root,
bound: rightTreeSize.bound,
children: rightTreeSize.children,
};
layoutTree(mockRoot, LayoutType.RIGHT, mindmap, [0]);
}
};
export const layout = (
root: MindmapNode,
mindmap: MindmapElementModel,
layoutDir: LayoutType | null,
path: number[]
) => {
layoutDir = layoutDir ?? mindmap.layoutType;
switch (layoutDir) {
case LayoutType.RIGHT:
return layoutRight(root, mindmap, path);
case LayoutType.LEFT:
return layoutLeft(root, mindmap, path);
case LayoutType.BALANCE:
return layoutBalance(root, mindmap, path);
}
};

View File

@@ -0,0 +1,680 @@
import { html } from 'lit';
export const MindmapStyleOne = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="64" height="48" rx="4" />
<path d="M23.976 24L43.4022 24" stroke="#E660A4" stroke-width="1.4" />
<path
d="M23.976 24.0001L26.5099 24.0001C30.4749 24.0001 33.6891 20.7858 33.6891 16.8208V16.8208C33.6891 12.8559 36.9034 9.6416 40.8684 9.6416L43.4022 9.6416"
stroke="#6E52DF"
stroke-width="1.4"
/>
<path
d="M23.976 24L26.5099 24C30.4749 24 33.6891 27.2143 33.6891 31.1792V31.1792C33.6891 35.1442 36.9034 38.3585 40.8684 38.3585L43.4022 38.3585"
stroke="#FF8C38"
stroke-width="1.4"
/>
<g>
<rect
x="42.7023"
y="33.4353"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FF8C38"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="42.7023"
y="19.0769"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#E660A4"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="42.7023"
y="4.71846"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#6E52DF"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
<g>
<rect
x="5.53921"
y="17.3879"
width="18.2923"
height="13.2246"
rx="2.77776"
stroke="#84CFFF"
stroke-width="1.4"
shape-rendering="crispEdges"
/>
</g>
</svg> `;
export const MindmapStyleTwo = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.9761 24L43.4023 24"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<path
d="M23.9761 24.0001L33.5892 24.0001C33.6444 24.0001 33.6892 23.9553 33.6892 23.9001L33.6892 9.7416C33.6892 9.68637 33.7339 9.6416 33.7892 9.6416L43.4023 9.6416"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<path
d="M23.9761 24L33.5892 24C33.6444 24 33.6892 24.0448 33.6892 24.1L33.6892 38.2585C33.6892 38.3137 33.7339 38.3585 33.7892 38.3585L43.4023 38.3585"
stroke="black"
stroke-width="1.4"
stroke-linejoin="round"
/>
<g filter="url(#filter0_dd_7851_11431)">
<rect
x="43.4023"
y="34.1353"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter1_dd_7851_11431)">
<rect
x="43.4023"
y="19.7769"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter2_dd_7851_11431)">
<rect
x="43.4023"
y="5.41846"
width="14.3585"
height="8.44617"
rx="0.1"
fill="#84CFFF"
/>
</g>
<g filter="url(#filter3_dd_7851_11431)">
<rect
x="6.23926"
y="18.0879"
width="16.8923"
height="11.8246"
rx="0.1"
fill="#FFC46B"
/>
</g>
<defs>
<filter
id="filter0_dd_7851_11431"
x="42.4023"
y="33.1353"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter1_dd_7851_11431"
x="42.4023"
y="18.7769"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter2_dd_7851_11431"
x="42.4023"
y="4.41846"
width="17.3585"
height="11.4463"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
<filter
id="filter3_dd_7851_11431"
x="5.23926"
y="17.0879"
width="19.8923"
height="14.8247"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect1_dropShadow_7851_11431"
/>
<feOffset dx="1" dy="1" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_11431"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology
radius="1"
operator="dilate"
in="SourceAlpha"
result="effect2_dropShadow_7851_11431"
/>
<feOffset />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_7851_11431"
result="effect2_dropShadow_7851_11431"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_7851_11431"
result="shape"
/>
</filter>
</defs>
</svg> `;
export const MindmapStyleThree = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.976 24L43.4022 24" stroke="#FFD338" stroke-width="1.4" />
<path
d="M23.976 24.0001L26.5099 24.0001C30.4749 24.0001 33.6891 20.7858 33.6891 16.8208V16.8208C33.6891 12.8559 36.9034 9.6416 40.8684 9.6416L43.4022 9.6416"
stroke="#FFD338"
stroke-width="1.4"
/>
<path
d="M23.976 24L26.5099 24C30.4749 24 33.6891 27.2143 33.6891 31.1792V31.1792C33.6891 35.1442 36.9034 38.3585 40.8684 38.3585L43.4022 38.3585"
stroke="#FFD338"
stroke-width="1.4"
/>
<g filter="url(#filter0_d_7851_6843)">
<rect
x="43.4023"
y="34.1353"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="33.4353"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter1_d_7851_6843)">
<rect
x="43.4023"
y="19.7769"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="19.0769"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter2_d_7851_6843)">
<rect
x="43.4023"
y="5.41846"
width="14.3585"
height="8.44617"
rx="1.68923"
fill="white"
/>
<rect
x="42.7023"
y="4.71846"
width="15.7585"
height="9.84617"
rx="2.38923"
stroke="#FFD338"
stroke-width="1.4"
/>
</g>
<g filter="url(#filter3_d_7851_6843)">
<rect
x="6.23921"
y="18.0879"
width="16.8923"
height="11.8246"
rx="2.07776"
fill="#FFD338"
/>
<rect
x="5.53921"
y="17.3879"
width="18.2923"
height="13.2246"
rx="2.77776"
stroke="white"
stroke-width="1.4"
/>
</g>
<defs>
<filter
id="filter0_d_7851_6843"
x="39.4684"
y="30.2015"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter1_d_7851_6843"
x="39.4684"
y="15.8431"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter2_d_7851_6843"
x="39.4684"
y="1.4847"
width="22.2262"
height="16.3138"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
<filter
id="filter3_d_7851_6843"
x="2.30537"
y="14.1541"
width="24.76"
height="19.6922"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="1.26693" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.12 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_7851_6843"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_7851_6843"
result="shape"
/>
</filter>
</defs>
</svg> `;
export const MindmapStyleFour = html`<svg
width="64"
height="48"
viewBox="0 0 64 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M23.9761 24L43.4023 24"
stroke="#E660A4"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M23.9761 24.0001L26.5099 24.0001C30.4749 24.0001 33.6892 20.7858 33.6892 16.8208V16.8208C33.6892 12.8559 36.9034 9.6416 40.8684 9.6416L43.4023 9.6416"
stroke="#6E52DF"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M23.9761 24L26.5099 24C30.4749 24 33.6892 27.2143 33.6892 31.1792V31.1792C33.6892 35.1442 36.9034 38.3585 40.8684 38.3585L43.4023 38.3585"
stroke="#FF8C38"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M7 26C9.85124 21.8236 11.8347 18.5607 11.8347 20.649C11.8347 22.7372 10.9669 24.695 11.2149 25.3475C11.4628 26 12.9504 24.0423 13.9421 22.8677C14.9339 21.6931 15.9256 21.0405 15.8017 22.8677C15.6777 24.6949 16.2975 25.739 17.2893 24.8254C18.281 23.9118 19.2727 23.5203 20.0165 23.7813C20.6116 23.9901 21.5868 24.5644 22 24.8254"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 11C46.4711 7.51964 48.1901 4.80062 48.1901 6.54079C48.1901 8.28097 47.438 9.91246 47.6529 10.4562C47.8678 11 49.157 9.36862 50.0165 8.38977C50.876 7.41092 51.7355 6.86712 51.6281 8.38977C51.5207 9.91243 52.0579 10.7825 52.9174 10.0212C53.7769 9.25986 54.6364 8.93358 55.281 9.1511C55.7967 9.32512 56.6419 9.80367 57 10.0212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 25.5C46.4711 22.0196 48.1901 19.3006 48.1901 21.0408C48.1901 22.781 47.438 24.4125 47.6529 24.9562C47.8678 25.5 49.157 23.8686 50.0165 22.8898C50.876 21.9109 51.7355 21.3671 51.6281 22.8898C51.5207 24.4124 52.0579 25.2825 52.9174 24.5212C53.7769 23.7599 54.6364 23.4336 55.281 23.6511C55.7967 23.8251 56.6419 24.3037 57 24.5212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
<path
d="M44 40C46.4711 36.5196 48.1901 33.8006 48.1901 35.5408C48.1901 37.281 47.438 38.9125 47.6529 39.4562C47.8678 40 49.157 38.3686 50.0165 37.3898C50.876 36.4109 51.7355 35.8671 51.6281 37.3898C51.5207 38.9124 52.0579 39.7825 52.9174 39.0212C53.7769 38.2599 54.6364 37.9336 55.281 38.1511C55.7967 38.3251 56.6419 38.8037 57 39.0212"
stroke="black"
stroke-width="1.4"
stroke-linecap="round"
/>
</svg> `;

View File

@@ -0,0 +1,692 @@
import { fitContent } from '@blocksuite/affine-gfx-shape';
import {
applyNodeStyle,
LayoutType,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
type MindmapStyle,
type NodeDetail,
type NodeType,
type ShapeElementModel,
} from '@blocksuite/affine-model';
import type { IVec } from '@blocksuite/global/gfx';
import { assertType } from '@blocksuite/global/utils';
import {
generateKeyBetween,
type SurfaceBlockModel,
} from '@blocksuite/std/gfx';
import isEqual from 'lodash-es/isEqual';
import last from 'lodash-es/last';
import * as Y from 'yjs';
import { layout } from './layout.js';
export function getHoveredArea(
target: ShapeElementModel,
position: [number, number],
layoutDir: LayoutType
): 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' {
const { x, y, w, h } = target;
const center =
layoutDir === LayoutType.BALANCE
? [x + w / 2, y + h / 2]
: layoutDir === LayoutType.LEFT
? [x + (w / 3) * 1, y + h / 2]
: [x + (w / 3) * 2, y + h / 2];
return `${position[1] - center[1] > 0 ? 'bottom' : 'top'}-${position[0] - center[0] > 0 ? 'right' : 'left'}`;
}
/**
* Hide the connector between the target node and its parent
*/
export function hideNodeConnector(
mindmap: MindmapElementModel,
/**
* The mind map node which's connector will be hide
*/
target: MindmapNode
) {
const parent = mindmap.getParentNode(target.id);
if (!parent) {
return;
}
const connectorId = `#${parent.id}-${target.id}`;
const connector = mindmap.connectors.get(connectorId);
if (!connector) {
return;
}
connector.opacity = 0;
return () => {
connector.opacity = 1;
};
}
/**
* Move the node to the new parent within the same mind map
* @param mindmap
* @param node the node should already exist in the mind map
* @param parent
* @param targetIndex
* @param layout
* @returns
*/
function moveNodePosition(
mindmap: MindmapElementModel,
node: MindmapNode,
parent: string | MindmapNode,
targetIndex: number,
layout?: LayoutType
) {
parent = mindmap.nodeMap.get(
typeof parent === 'string' ? parent : parent.id
)!;
if (!parent || !mindmap.nodeMap.has(node.id)) {
return;
}
assertType<MindmapNode>(parent);
if (layout === LayoutType.BALANCE || parent !== mindmap.tree) {
layout = undefined;
}
const siblings = parent.children.filter(child => child !== node);
targetIndex = Math.min(targetIndex, parent.children.length);
siblings.splice(targetIndex, 0, node);
// calculate the index
// the sibling node may be the same node, so we need to filter it out
const preSibling = siblings[targetIndex - 1];
const afterSibling = siblings[targetIndex + 1];
const index =
preSibling || afterSibling
? generateKeyBetween(
preSibling?.detail.index ?? null,
afterSibling?.detail.index ?? null
)
: (node.detail.index ?? undefined);
mindmap.surface.store.transact(() => {
const val: NodeDetail = {
...node.detail,
index,
parent: parent.id,
};
mindmap.children.set(node.id, val);
});
if (parent.detail.collapsed) {
mindmap.toggleCollapse(parent);
}
mindmap.layout();
return mindmap.nodeMap.get(node.id);
}
export function applyStyle(
mindmap: MindmapElementModel,
shouldFitContent: boolean = false
) {
mindmap.surface.store.transact(() => {
const style = mindmap.styleGetter;
if (!style) return;
applyNodeStyle(mindmap.tree, style.root);
if (shouldFitContent) {
fitContent(mindmap.tree.element as ShapeElementModel);
}
const walk = (node: MindmapNode, path: number[]) => {
node.children.forEach((child, idx) => {
const currentPath = [...path, idx];
const nodeStyle = style.getNodeStyle(child, currentPath);
applyNodeStyle(child, nodeStyle.node);
if (shouldFitContent) {
fitContent(child.element as ShapeElementModel);
}
walk(child, currentPath);
});
};
walk(mindmap.tree, [0]);
});
}
/**
*
* @param mindmap the mind map to add the node to
* @param parent the parent node or the parent node id
* @param node the node must be an detached node
* @param targetIndex the index to insert the node at
* @returns
*/
export function addNode(
mindmap: MindmapElementModel,
parent: string | MindmapNode,
node: MindmapNode,
targetIndex?: number
) {
const parentNode = mindmap.getNode(
typeof parent === 'string' ? parent : parent.id
);
if (!parentNode) {
return;
}
const children = parentNode.children.slice();
targetIndex =
targetIndex !== undefined
? Math.min(targetIndex, children.length)
: children.length;
children.splice(targetIndex, 0, node);
const before = children[targetIndex - 1] ?? null;
const after = children[targetIndex + 1] ?? null;
const index =
before || after
? generateKeyBetween(
before?.detail.index ?? null,
after?.detail.index ?? null
)
: node.detail.index;
mindmap.surface.store.transact(() => {
mindmap.children.set(node.id, {
...node.detail,
index,
parent: parentNode.id,
});
const recursiveAddChild = (node: MindmapNode) => {
node.children?.forEach(child => {
mindmap.children.set(child.id, {
...child.detail,
parent: node.id,
});
recursiveAddChild(child);
});
};
recursiveAddChild(node);
});
if (parentNode.detail.collapsed) {
mindmap.toggleCollapse(parentNode);
}
mindmap.layout();
}
export function addTree(
mindmap: MindmapElementModel,
parent: string | MindmapNode,
tree: NodeType | MindmapNode,
/**
* `sibling` indicates where to insert a subtree among peer elements.
* If it's a string, it represents a peer element's ID;
* if it's a number, it represents its index.
* The subtree will be inserted before the sibling element.
*/
sibling?: string | number
) {
parent = typeof parent === 'string' ? parent : parent.id;
if (!mindmap.nodeMap.has(parent) || !parent) {
return null;
}
assertType<string>(parent);
const traverse = (
node: NodeType | MindmapNode,
parent: string,
sibling?: string | number
) => {
let nodeId: string;
if ('text' in node) {
nodeId = mindmap.addNode(parent, sibling, 'before', {
text: node.text,
});
} else {
mindmap.children.set(node.id, {
...node.detail,
parent,
});
nodeId = node.id;
}
node.children?.forEach(child => traverse(child, nodeId));
return nodeId;
};
if (!('text' in tree)) {
// Modify the children ymap directly hence need transaction
mindmap.surface.store.transact(() => {
traverse(tree, parent, sibling);
});
applyStyle(mindmap);
mindmap.layout();
return mindmap.nodeMap.get(tree.id);
} else {
const nodeId = traverse(tree, parent, sibling);
mindmap.layout();
return mindmap.nodeMap.get(nodeId);
}
}
/**
* Detach a mindmap node or subtree. It is similar to `removeChild` but
* it does not delete the node.
*
* So the node can be used to create a new mind map or merge into other mind map
* @param mindmap the mind map that the subtree belongs to
* @param subtree the subtree to detach
*/
export function detachMindmap(
mindmap: MindmapElementModel,
subtree: string | MindmapNode
) {
subtree =
typeof subtree === 'string' ? mindmap.nodeMap.get(subtree)! : subtree;
assertType<MindmapNode>(subtree);
if (!subtree) return;
const traverse = (subtree: MindmapNode) => {
mindmap.children.delete(subtree.id);
// cut the reference inside the ymap
subtree.detail = {
...subtree.detail,
};
subtree.children.forEach(child => traverse(child));
};
mindmap.surface.store.transact(() => {
traverse(subtree);
});
mindmap.layout();
delete subtree.detail.parent;
return subtree;
}
export function handleLayout(
mindmap: MindmapElementModel,
tree?: MindmapNode | MindmapRoot,
shouldApplyStyle = true,
layoutType?: LayoutType
) {
if (!tree || !tree.element) return;
if (shouldApplyStyle) {
applyStyle(mindmap, true);
}
mindmap.surface.store.transact(() => {
const path = mindmap.getPath(tree.id);
layout(tree, mindmap, layoutType ?? mindmap.getLayoutDir(tree.id), path);
});
}
export function createFromTree(
tree: MindmapNode,
style: MindmapStyle,
layoutType: LayoutType,
surface: SurfaceBlockModel
) {
const children = new Y.Map();
const traverse = (subtree: MindmapNode, parent?: string) => {
const value: NodeDetail = {
...subtree.detail,
parent,
};
if (!parent) {
delete value.parent;
}
children.set(subtree.id, value);
subtree.children.forEach(child => traverse(child, subtree.id));
};
traverse(tree);
const mindmapId = surface.addElement({
type: 'mindmap',
children,
layoutType,
style,
});
const mindmap = surface.getElementById(mindmapId) as MindmapElementModel;
handleLayout(mindmap, mindmap.tree, true, mindmap.layoutType);
return mindmap;
}
/**
* Move a subtree from one mind map to another
* @param from the mind map that the `subtree` belongs to
* @param subtree the subtree to move
* @param to the mind map to move the `subtree` to
* @param parent the new parent node to attach the `subtree` to
* @param index the index to insert the `subtree` at
*/
export function moveNode(
from: MindmapElementModel,
subtree: MindmapNode,
to: MindmapElementModel,
parent: MindmapNode | string,
index: number
) {
if (from === to) {
return moveNodePosition(from, subtree, parent, index);
}
if (!detachMindmap(from, subtree)) return;
return addNode(to, parent, subtree, index);
}
export function findTargetNode(
mindmap: MindmapElementModel,
position: IVec
): MindmapNode | null {
const find = (node: MindmapNode): MindmapNode | null => {
if (!node.responseArea) {
return null;
}
const layoutDir = mindmap.getLayoutDir(node);
if (
layoutDir === LayoutType.BALANCE ||
(layoutDir === LayoutType.RIGHT &&
position[0] > node.element.x + node.element.w) ||
(layoutDir === LayoutType.LEFT && position[0] < node.element.x)
) {
for (const child of node.children) {
const result = find(child);
if (result) {
return result;
}
}
}
return node.responseArea.containsPoint(position) ? node : null;
};
return find(mindmap.tree);
}
function determineInsertPosition(
mindmap: MindmapElementModel,
mindmapNode: MindmapNode,
position: IVec
):
| {
type: 'child';
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
}
| {
type: 'sibling';
layoutDir: LayoutType.LEFT | LayoutType.RIGHT;
position: 'prev' | 'next';
}
| null {
if (
!mindmapNode.responseArea ||
!mindmapNode.responseArea.containsPoint(position)
) {
return null;
}
const layoutDir = mindmap.getLayoutDir(mindmapNode);
const elementBound = mindmapNode.element.elementBound;
const targetLayout: LayoutType.LEFT | LayoutType.RIGHT =
layoutDir === LayoutType.BALANCE
? position[0] > elementBound.x + elementBound.w / 2
? LayoutType.RIGHT
: LayoutType.LEFT
: layoutDir;
if (
elementBound.containsPoint(position) ||
(layoutDir === LayoutType.RIGHT
? position[0] > elementBound.x + elementBound.w
: position[0] < elementBound.x)
) {
return {
type: 'child',
layoutDir: targetLayout,
};
}
if (
mindmap.layoutType === LayoutType.BALANCE &&
mindmap.getPath(mindmapNode.id).length === 2
) {
return {
type: 'sibling',
layoutDir: targetLayout,
position:
targetLayout === LayoutType.LEFT
? position[1] > elementBound.y + elementBound.h / 2
? 'prev'
: 'next'
: position[1] > elementBound.y + elementBound.h / 2
? 'next'
: 'prev',
};
}
return {
type: 'sibling',
layoutDir: targetLayout,
position:
position[1] > elementBound.y + elementBound.h / 2 ? 'next' : 'prev',
};
}
function showMergeIndicator(
targetMindMap: MindmapElementModel,
target: MindmapNode,
sourceMindMap: MindmapElementModel,
source: MindmapNode,
insertPosition:
| {
type: 'sibling';
layoutDir: Exclude<LayoutType, LayoutType.BALANCE>;
position: 'prev' | 'next';
}
| { type: 'child'; layoutDir: Exclude<LayoutType, LayoutType.BALANCE> },
callback: (option: {
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[];
}) => () => void
) {
const newParent =
insertPosition.type === 'child'
? target
: targetMindMap.getParentNode(target.id)!;
if (!newParent) {
return null;
}
const path = targetMindMap.getPath(newParent);
const curPath = sourceMindMap.getPath(source.id);
if (insertPosition.type === 'sibling') {
const curPath = targetMindMap.getPath(target.id);
const parent = targetMindMap.getParentNode(target.id);
if (!parent) {
return null;
}
const idx = parent.children
.filter(child => child.id !== source.id)
.indexOf(target);
path.push(
idx === -1
? Math.max(
0,
last(curPath)! + (insertPosition.position === 'next' ? 1 : 0)
)
: Math.max(0, idx + (insertPosition.position === 'next' ? 1 : 0))
);
} else {
path.push(target.children.length);
}
// hide original connector
const abortPreview = callback({
targetMindMap,
target: target,
sourceMindMap,
source,
newParent,
insertPosition,
path,
});
const abort = () => {
abortPreview?.();
};
const merge = () => {
abort();
if (targetMindMap === sourceMindMap && isEqual(path, curPath)) {
return;
}
moveNode(sourceMindMap, source, targetMindMap, newParent, last(path)!);
};
return {
abort,
merge,
};
}
/**
* Try to move a node to another mind map.
* It will show a merge indicator if the node can be merged to the target mind map.
* @param targetMindMap
* @param target
* @param sourceMindMap
* @param source
* @param position
* @return return two functions, `abort` and `merge`. `abort` will cancel the operation and `merge` will merge the node to the target mind map.
*/
export function tryMoveNode(
targetMindMap: MindmapElementModel,
target: MindmapNode,
sourceMindMap: MindmapElementModel,
source: MindmapNode,
position: IVec,
callback: (option: {
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[];
}) => () => void
) {
const insertInfo = determineInsertPosition(targetMindMap, target, position);
if (!insertInfo) {
return null;
}
return showMergeIndicator(
targetMindMap,
target,
sourceMindMap,
source,
insertInfo,
callback
);
}
/**
* Check if the mind map contains the target node.
* @param mindmap Mind map to check
* @param targetNode Node to check
* @param searchParent If provided, check if the node is a descendant of the parent node. Otherwise, check the whole mind map.
* @returns
*/
export function containsNode(
mindmap: MindmapElementModel,
targetNode: MindmapNode,
searchParent?: MindmapNode
) {
searchParent = searchParent ?? mindmap.tree;
const find = (checkAgainstNode: MindmapNode) => {
if (checkAgainstNode.id === targetNode.id) {
return true;
}
for (const child of checkAgainstNode.children) {
if (find(child)) {
return true;
}
}
return false;
};
return find(searchParent);
}

View File

@@ -0,0 +1,378 @@
import {
LayoutType,
LocalShapeElementModel,
type MindmapElementModel,
type MindmapNode,
type MindmapRoot,
} from '@blocksuite/affine-model';
import { TelemetryProvider } from '@blocksuite/affine-shared/services';
import { requestThrottledConnectedFrame } from '@blocksuite/affine-shared/utils';
import { Bound } from '@blocksuite/global/gfx';
import type { PointerEventState } from '@blocksuite/std';
import {
type BoxSelectionContext,
GfxElementModelView,
GfxViewInteractionExtension,
} from '@blocksuite/std/gfx';
import { handleLayout } from './utils.js';
export class MindMapView extends GfxElementModelView<MindmapElementModel> {
static override type = 'mindmap';
private readonly _collapseButtons = new Map<string, LocalShapeElementModel>();
private _hoveredState = new Map<
string,
{
button: boolean;
node: boolean;
}
>();
/**
*
* @param node The mindmap node or its id to get the collapse button
* @returns
*/
getCollapseButton(node: MindmapNode | string) {
const id = typeof node === 'string' ? node : node.id;
return this._collapseButtons.get(`collapse-btn-${id}`);
}
private _initCollapseButtons() {
const updateButtons = requestThrottledConnectedFrame(() => {
if (!this.isConnected) {
return;
}
const visited = new Set<LocalShapeElementModel>();
this.model.traverse(node => {
const btn = this._updateCollapseButton(node);
btn && visited.add(btn);
});
this._collapseButtons.forEach(btn => {
if (!visited.has(btn)) {
this.surface.deleteLocalElement(btn);
this._collapseButtons.delete(btn.id);
const hoveredId = btn.id.replace('collapse-btn-', '');
this._hoveredState.delete(hoveredId);
}
});
});
this.disposable.add(
this.model.propsUpdated.subscribe(({ key }) => {
if (key === 'layoutType' || key === 'style') {
updateButtons();
}
})
);
this.disposable.add(
this.surface.elementUpdated.subscribe(payload => {
if (this.model.children.has(payload.id)) {
if (payload.props['xywh']) {
updateButtons();
}
if (
payload.props['hidden'] !== undefined ||
payload.props['opacity'] !== undefined
) {
this._updateButtonVisibility(payload.id);
}
}
})
);
this.model.children.observe(updateButtons);
this.disposable.add(() => {
this.model.children.unobserve(updateButtons);
});
updateButtons();
}
private _needToUpdateButtonStyle(options: {
button: LocalShapeElementModel;
node: MindmapNode;
updateKey?: boolean;
}) {
const { button, node } = options;
const layout = this.model.getLayoutDir(node);
const cacheKey = `${node.detail.collapsed ?? false}-${layout}-${node.element.xywh}-${this.model.style}`;
if (button.cache.get('MINDMAP_COLLAPSE_BUTTON') === cacheKey) {
return false;
} else if (options.updateKey) {
button.cache.set('MINDMAP_COLLAPSE_BUTTON', cacheKey);
}
return true;
}
private _setLayoutMethod() {
this.model.setLayoutMethod(function (
this: MindmapElementModel,
tree: MindmapNode | MindmapRoot | undefined,
options:
| {
applyStyle?: boolean;
layoutType?: LayoutType;
stashed?: boolean;
}
| undefined
) {
const { stashed, applyStyle, layoutType } = Object.assign(
{
applyStyle: true,
calculateTreeBound: true,
stashed: true,
},
options
);
const targetTree = tree ?? this.tree;
const pop = stashed ? this.stashTree(targetTree) : null;
handleLayout(this, targetTree, applyStyle, layoutType);
pop?.();
});
}
private _setVisibleOnSelection() {
let lastNode: null | string = null;
this.disposable.add(
this.gfx.selection.slots.updated.subscribe(() => {
const elm = this.gfx.selection.firstElement;
if (lastNode) {
this._updateButtonVisibility(lastNode);
}
if (
this.gfx.selection.selectedElements.length === 1 &&
elm?.id &&
this.model.children.has(elm.id)
) {
const button = this.getCollapseButton(elm.id);
if (!button) {
return;
}
this._updateButtonVisibility(elm.id);
lastNode = elm.id;
}
})
);
}
private _updateButtonVisibility(node: string) {
const latestNode = this.model.getNode(node);
const buttonModel = this.getCollapseButton(node);
if (!buttonModel) {
return;
}
if (!latestNode) {
buttonModel.opacity = 0;
buttonModel.hidden = true;
return;
}
const hoveredState = this._hoveredState.get(node) ?? {
button: false,
node: false,
};
const hovered = hoveredState.button || hoveredState.node;
const hasChildren = (latestNode.children.length ?? 0) > 0;
const notHidden = !latestNode.element.hidden;
const isNodeSelected =
this.gfx.selection.firstElement === latestNode.element;
const collapsed = latestNode.detail.collapsed ?? false;
buttonModel.hidden = latestNode.element.hidden;
buttonModel.opacity =
(hasChildren && notHidden && (collapsed || isNodeSelected || hovered)
? 1
: 0) * (latestNode.element.opacity ?? 1);
}
private _updateCollapseButton(node: MindmapNode) {
if (!node?.element || node.children.length === 0) return null;
const id = `collapse-btn-${node.id}`;
const alreadyCreated = this._collapseButtons.has(id);
const collapseButton =
this._collapseButtons.get(id) ||
new LocalShapeElementModel(this.model.surface);
const collapsed = node.detail.collapsed ?? false;
if (
this._needToUpdateButtonStyle({
button: collapseButton,
node,
updateKey: true,
})
) {
const style = this.model.styleGetter.getNodeStyle(
node,
this.model.getPath(node)
);
const layout = this.model.getLayoutDir(node);
const buttonStyle = collapsed ? style.expandButton : style.collapseButton;
Object.entries(buttonStyle).forEach(([key, value]) => {
// @ts-expect-error key is string
collapseButton[key as unknown] = value;
});
const nodeElementBound = node.element.elementBound;
const buttonBound = nodeElementBound.moveDelta(
layout === LayoutType.LEFT
? -6 - buttonStyle.width
: 6 + nodeElementBound.w,
(nodeElementBound.h - buttonStyle.height) / 2
);
buttonBound.w = buttonStyle.width;
buttonBound.h = buttonStyle.height;
collapseButton.responseExtension = [16, 16];
collapseButton.xywh = buttonBound.serialize();
collapseButton.groupId = this.model.id;
collapseButton.text = collapsed ? node.children.length.toString() : '';
}
if (!alreadyCreated) {
collapseButton.id = id;
collapseButton.opacity = !node.element.hidden && collapsed ? 1 : 0;
this._collapseButtons.set(id, collapseButton);
this.surface.addLocalElement(collapseButton);
const hoveredState = {
button: false,
node: false,
};
const buttonView = this.gfx.view.get(id) as GfxElementModelView;
const isOnElementBound = (evt: PointerEventState) => {
const [x, y] = this.gfx.viewport.toModelCoord(evt.x, evt.y);
return buttonView.model.includesPoint(
x,
y,
{ useElementBound: true },
this.gfx.std.host
);
};
this._hoveredState = this._hoveredState.set(node.id, hoveredState);
buttonView.on('pointerenter', () => {
hoveredState.button = true;
this._updateButtonVisibility(node.id);
});
buttonView.on('pointermove', evt => {
const latestNode = this.model.getNode(node.id);
if (
latestNode &&
!latestNode.element.hidden &&
latestNode.children.length > 0
) {
if (isOnElementBound(evt)) {
this.gfx.cursor$.value = 'pointer';
} else {
this.gfx.cursor$.value = 'default';
}
}
});
buttonView.on('pointerleave', () => {
this.gfx.cursor$.value = 'default';
hoveredState.button = false;
this._updateButtonVisibility(node.id);
});
buttonView.on('click', evt => {
const latestNode = this.model.getNode(node.id);
const telemetry = this.gfx.std.getOptional(TelemetryProvider);
if (latestNode && isOnElementBound(evt)) {
if (telemetry) {
telemetry.track('ExpandedAndCollapsed', {
page: 'whiteboard editor',
segment: 'mind map',
type: latestNode.detail.collapsed ? 'expand' : 'collapse',
});
}
this.model.toggleCollapse(latestNode!, { layout: true });
}
});
const nodeView = this.gfx.view.get(node.id) as GfxElementModelView;
nodeView.on('pointerenter', () => {
hoveredState.node = true;
this._updateButtonVisibility(node.id);
});
nodeView.on('pointerleave', () => {
hoveredState.node = false;
this._updateButtonVisibility(node.id);
});
} else {
this._updateButtonVisibility(node.id);
}
return collapseButton;
}
override onBoxSelected(context: BoxSelectionContext) {
const { box } = context;
const bound = new Bound(box.x, box.y, box.w, box.h);
return bound.contains(this.model.elementBound);
}
override onCreated(): void {
this._setLayoutMethod();
this._initCollapseButtons();
this._setVisibleOnSelection();
}
override onDestroyed() {
super.onDestroyed();
this._collapseButtons.forEach(btn => {
this.surface.deleteLocalElement(btn);
});
}
}
export const MindMapInteraction = GfxViewInteractionExtension<MindMapView>(
MindMapView.type,
{
resizeConstraint: {
allowedHandlers: [],
},
handleSelection: () => {
return {
onSelect(context) {
const { model } = context;
if (model.isLocked()) {
return context.default(context);
}
return false;
},
};
},
}
);

View File

@@ -0,0 +1,26 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
},
"include": ["./src"],
"references": [
{ "path": "../../blocks/attachment" },
{ "path": "../../blocks/image" },
{ "path": "../../blocks/surface" },
{ "path": "../../components" },
{ "path": "../../ext-loader" },
{ "path": "../connector" },
{ "path": "../pointer" },
{ "path": "../shape" },
{ "path": "../text" },
{ "path": "../../model" },
{ "path": "../../shared" },
{ "path": "../../widgets/edgeless-toolbar" },
{ "path": "../../../framework/global" },
{ "path": "../../../framework/std" },
{ "path": "../../../framework/store" }
]
}