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
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:
49
blocksuite/affine/gfx/mindmap/package.json
Normal file
49
blocksuite/affine/gfx/mindmap/package.json
Normal 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"
|
||||
}
|
||||
2
blocksuite/affine/gfx/mindmap/src/adapter/index.ts
Normal file
2
blocksuite/affine/gfx/mindmap/src/adapter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './markdown';
|
||||
export * from './plain-text';
|
||||
69
blocksuite/affine/gfx/mindmap/src/adapter/markdown/index.ts
Normal file
69
blocksuite/affine/gfx/mindmap/src/adapter/markdown/index.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
65
blocksuite/affine/gfx/mindmap/src/adapter/utils.ts
Normal file
65
blocksuite/affine/gfx/mindmap/src/adapter/utils.ts
Normal 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;
|
||||
}
|
||||
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal file
20
blocksuite/affine/gfx/mindmap/src/effects.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
8
blocksuite/affine/gfx/mindmap/src/index.ts
Normal file
8
blocksuite/affine/gfx/mindmap/src/index.ts
Normal 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';
|
||||
301
blocksuite/affine/gfx/mindmap/src/indicator-overlay/index.ts
Normal file
301
blocksuite/affine/gfx/mindmap/src/indicator-overlay/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
190
blocksuite/affine/gfx/mindmap/src/interactivity/drag-utils.ts
Normal file
190
blocksuite/affine/gfx/mindmap/src/interactivity/drag-utils.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
1
blocksuite/affine/gfx/mindmap/src/interactivity/index.ts
Normal file
1
blocksuite/affine/gfx/mindmap/src/interactivity/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './mind-map-drag';
|
||||
465
blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts
Normal file
465
blocksuite/affine/gfx/mindmap/src/interactivity/mind-map-drag.ts
Normal 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 {};
|
||||
});
|
||||
}
|
||||
}
|
||||
65
blocksuite/affine/gfx/mindmap/src/renderer/dom-renderer.ts
Normal file
65
blocksuite/affine/gfx/mindmap/src/renderer/dom-renderer.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
@@ -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
|
||||
);
|
||||
2
blocksuite/affine/gfx/mindmap/src/renderer/index.ts
Normal file
2
blocksuite/affine/gfx/mindmap/src/renderer/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './dom-renderer';
|
||||
export * from './element-renderer';
|
||||
19
blocksuite/affine/gfx/mindmap/src/store.ts
Normal file
19
blocksuite/affine/gfx/mindmap/src/store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
blocksuite/affine/gfx/mindmap/src/toolbar/assets.ts
Normal file
46
blocksuite/affine/gfx/mindmap/src/toolbar/assets.ts
Normal 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),
|
||||
},
|
||||
];
|
||||
206
blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts
Normal file
206
blocksuite/affine/gfx/mindmap/src/toolbar/basket-elements.ts
Normal 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;
|
||||
};
|
||||
199
blocksuite/affine/gfx/mindmap/src/toolbar/config.ts
Normal file
199
blocksuite/affine/gfx/mindmap/src/toolbar/config.ts
Normal 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,
|
||||
});
|
||||
619
blocksuite/affine/gfx/mindmap/src/toolbar/icons.ts
Normal file
619
blocksuite/affine/gfx/mindmap/src/toolbar/icons.ts
Normal file
File diff suppressed because one or more lines are too long
@@ -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>`;
|
||||
}
|
||||
}
|
||||
423
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-menu.ts
Normal file
423
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-menu.ts
Normal 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;
|
||||
}
|
||||
468
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-tool-button.ts
Normal file
468
blocksuite/affine/gfx/mindmap/src/toolbar/mindmap-tool-button.ts
Normal 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;
|
||||
}
|
||||
15
blocksuite/affine/gfx/mindmap/src/toolbar/senior-tool.ts
Normal file
15
blocksuite/affine/gfx/mindmap/src/toolbar/senior-tool.ts
Normal 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>`,
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
70
blocksuite/affine/gfx/mindmap/src/utils/index.ts
Normal file
70
blocksuite/affine/gfx/mindmap/src/utils/index.ts
Normal 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];
|
||||
}
|
||||
40
blocksuite/affine/gfx/mindmap/src/view.ts
Normal file
40
blocksuite/affine/gfx/mindmap/src/view.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
blocksuite/affine/gfx/mindmap/src/view/index.ts
Normal file
4
blocksuite/affine/gfx/mindmap/src/view/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './layout';
|
||||
export * from './style-svg';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
201
blocksuite/affine/gfx/mindmap/src/view/layout.ts
Normal file
201
blocksuite/affine/gfx/mindmap/src/view/layout.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
680
blocksuite/affine/gfx/mindmap/src/view/style-svg.ts
Normal file
680
blocksuite/affine/gfx/mindmap/src/view/style-svg.ts
Normal 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> `;
|
||||
692
blocksuite/affine/gfx/mindmap/src/view/utils.ts
Normal file
692
blocksuite/affine/gfx/mindmap/src/view/utils.ts
Normal 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);
|
||||
}
|
||||
378
blocksuite/affine/gfx/mindmap/src/view/view.ts
Normal file
378
blocksuite/affine/gfx/mindmap/src/view/view.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
);
|
||||
26
blocksuite/affine/gfx/mindmap/tsconfig.json
Normal file
26
blocksuite/affine/gfx/mindmap/tsconfig.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user