mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './mindmap.js';
|
||||
export * from './style.js';
|
||||
@@ -0,0 +1,975 @@
|
||||
import type {
|
||||
BaseElementProps,
|
||||
GfxModel,
|
||||
PointTestOptions,
|
||||
SerializedElement,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import {
|
||||
convert,
|
||||
field,
|
||||
GfxGroupLikeElementModel,
|
||||
observe,
|
||||
watch,
|
||||
} from '@blocksuite/block-std/gfx';
|
||||
import type { Bound, SerializedXYWH, XYWH } from '@blocksuite/global/utils';
|
||||
import {
|
||||
assertType,
|
||||
deserializeXYWH,
|
||||
keys,
|
||||
last,
|
||||
noop,
|
||||
pick,
|
||||
} from '@blocksuite/global/utils';
|
||||
import { DocCollection, type Y } from '@blocksuite/store';
|
||||
import { generateKeyBetween } from 'fractional-indexing';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ConnectorMode } from '../../consts/connector.js';
|
||||
import { LayoutType, MindmapStyle } from '../../consts/mindmap.js';
|
||||
import { LocalConnectorElementModel } from '../connector/local-connector.js';
|
||||
import type { MindmapStyleGetter } from './style.js';
|
||||
import { mindmapStyleGetters } from './style.js';
|
||||
import { findInfiniteLoop } from './utils.js';
|
||||
|
||||
export type NodeDetail = {
|
||||
/**
|
||||
* The index of the node, it decides the layout order of the node
|
||||
*/
|
||||
index: string;
|
||||
parent?: string;
|
||||
collapsed?: boolean;
|
||||
};
|
||||
|
||||
export type MindmapNode = {
|
||||
id: string;
|
||||
detail: NodeDetail;
|
||||
|
||||
element: BlockSuite.SurfaceElementModel;
|
||||
children: MindmapNode[];
|
||||
|
||||
parent: MindmapNode | null;
|
||||
|
||||
/**
|
||||
* This area is used to determine where to place the dragged node.
|
||||
*
|
||||
* When dragging another node into this area, it will become a sibling of the this node.
|
||||
* But if it is dragged into the small area located right after the this node, it will become a child of the this node.
|
||||
*/
|
||||
responseArea?: Bound;
|
||||
|
||||
/**
|
||||
* This property override the preferredDir or default layout direction.
|
||||
* It is used during dragging that would temporary change the layout direction
|
||||
*/
|
||||
overriddenDir?: LayoutType;
|
||||
};
|
||||
|
||||
export type MindmapRoot = MindmapNode & {
|
||||
left: MindmapNode[];
|
||||
right: MindmapNode[];
|
||||
};
|
||||
|
||||
const baseNodeSchema = z.object({
|
||||
text: z.string(),
|
||||
xywh: z.optional(z.string()),
|
||||
});
|
||||
|
||||
type Node = z.infer<typeof baseNodeSchema> & {
|
||||
children?: Node[];
|
||||
};
|
||||
|
||||
const nodeSchema: z.ZodType<Node> = baseNodeSchema.extend({
|
||||
children: z.lazy(() => nodeSchema.array()).optional(),
|
||||
});
|
||||
|
||||
export type NodeType = z.infer<typeof nodeSchema>;
|
||||
|
||||
function isNodeType(node: Record<string, unknown>): node is NodeType {
|
||||
return typeof node.text === 'string' && Array.isArray(node.children);
|
||||
}
|
||||
|
||||
export type SerializedMindmapElement = SerializedElement & {
|
||||
children: Record<string, NodeDetail>;
|
||||
};
|
||||
|
||||
type MindmapElementProps = BaseElementProps & {
|
||||
children: Y.Map<NodeDetail>;
|
||||
};
|
||||
|
||||
function observeChildren(
|
||||
_: unknown,
|
||||
instance: MindmapElementModel,
|
||||
transaction: Y.Transaction | null
|
||||
) {
|
||||
if (instance.children.doc) {
|
||||
instance.setChildIds(
|
||||
Array.from(instance.children.keys()),
|
||||
transaction?.local ?? true
|
||||
);
|
||||
|
||||
instance.buildTree();
|
||||
instance.connectors.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function watchLayoutType(
|
||||
_: unknown,
|
||||
instance: MindmapElementModel,
|
||||
local: boolean
|
||||
) {
|
||||
if (!local) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.surface.doc.transact(() => {
|
||||
instance['_tree']?.children.forEach(child => {
|
||||
if (!instance.children.has(child.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
instance.children.set(child.id, {
|
||||
index: child.detail.index,
|
||||
parent: child.detail.parent,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
instance.buildTree();
|
||||
}
|
||||
|
||||
function watchStyle(_: unknown, instance: MindmapElementModel, local: boolean) {
|
||||
if (!local) return;
|
||||
instance.layout();
|
||||
}
|
||||
|
||||
export class MindmapElementModel extends GfxGroupLikeElementModel<MindmapElementProps> {
|
||||
private _layout: MindmapElementModel['layout'] | null = null;
|
||||
|
||||
private _nodeMap = new Map<string, MindmapNode>();
|
||||
|
||||
private _queueBuildTree = false;
|
||||
|
||||
private _queuedLayout = false;
|
||||
|
||||
private _stashedNode = new Set<string>();
|
||||
|
||||
private _tree!: MindmapRoot;
|
||||
|
||||
connectors = new Map<string, LocalConnectorElementModel>();
|
||||
|
||||
get nodeMap() {
|
||||
return this._nodeMap;
|
||||
}
|
||||
|
||||
override get rotate() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
override set rotate(_: number) {}
|
||||
|
||||
get styleGetter(): MindmapStyleGetter {
|
||||
return mindmapStyleGetters[this.style];
|
||||
}
|
||||
|
||||
get tree() {
|
||||
return this._tree;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return 'mindmap';
|
||||
}
|
||||
|
||||
static override propsToY(props: Record<string, unknown>) {
|
||||
if (
|
||||
props.children &&
|
||||
!isNodeType(props.children as Record<string, unknown>) &&
|
||||
!(props.children instanceof DocCollection.Y.Map)
|
||||
) {
|
||||
const children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
|
||||
keys(props.children).forEach(key => {
|
||||
const detail = pick<Record<string, unknown>, keyof NodeDetail>(
|
||||
props.children![key],
|
||||
['index', 'parent']
|
||||
);
|
||||
children.set(key as string, detail as NodeDetail);
|
||||
});
|
||||
|
||||
props.children = children;
|
||||
}
|
||||
|
||||
return props as MindmapElementProps;
|
||||
}
|
||||
|
||||
private _cfgBalanceLayoutDir() {
|
||||
if (this.layoutType !== LayoutType.BALANCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tree = this._tree;
|
||||
const splitPoint = Math.ceil(tree.children.length / 2);
|
||||
|
||||
tree.right.push(...tree.children.slice(0, splitPoint));
|
||||
tree.left.push(...tree.children.slice(splitPoint));
|
||||
tree.left.reverse();
|
||||
}
|
||||
|
||||
private _isConnectorOutdated(
|
||||
options:
|
||||
| {
|
||||
connector: LocalConnectorElementModel;
|
||||
from: MindmapNode;
|
||||
to: MindmapNode;
|
||||
layout: LayoutType;
|
||||
}
|
||||
| {
|
||||
connector: LocalConnectorElementModel;
|
||||
from: MindmapNode;
|
||||
layout: LayoutType;
|
||||
collapsed: boolean;
|
||||
},
|
||||
updateKey: boolean = true
|
||||
) {
|
||||
const collapsed = 'collapsed' in options;
|
||||
const { connector, from, layout } = options;
|
||||
|
||||
if (!from.element || (!collapsed && !options.to.element)) {
|
||||
return { outdated: true, cacheKey: '' };
|
||||
}
|
||||
|
||||
const cacheKey = collapsed
|
||||
? `${from.element.xywh}-collapsed-${layout}-${this.style}`
|
||||
: `${from.element.xywh}-${options.to.element.xywh}-${layout}-${this.style}`;
|
||||
|
||||
if (connector.cache.get('MINDMAP_CONNECTOR') === cacheKey) {
|
||||
return false;
|
||||
} else if (updateKey) {
|
||||
connector.cache.set('MINDMAP_CONNECTOR', cacheKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected override _getXYWH(): Bound {
|
||||
return super._getXYWH();
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* you should not call this method directly
|
||||
*/
|
||||
addChild(_element: GfxModel) {
|
||||
noop();
|
||||
}
|
||||
|
||||
addNode(
|
||||
/**
|
||||
* The parent node id of the new node. If it's null, the node will be the root node
|
||||
*/
|
||||
parent: string | MindmapNode | null,
|
||||
sibling?: string | number,
|
||||
position: 'before' | 'after' = 'after',
|
||||
props: Record<string, unknown> = {}
|
||||
) {
|
||||
if (parent && typeof parent !== 'string') {
|
||||
parent = parent.id;
|
||||
}
|
||||
|
||||
assertType<string | null>(parent);
|
||||
|
||||
if (parent && !this._nodeMap.has(parent)) {
|
||||
throw new Error(`Parent node ${parent} not found`);
|
||||
}
|
||||
|
||||
props['text'] = new DocCollection.Y.Text(
|
||||
(props['text'] as string) ?? 'New node'
|
||||
);
|
||||
|
||||
const type = (props.type as string) ?? 'shape';
|
||||
let id: string;
|
||||
this.surface.doc.transact(() => {
|
||||
const parentNode = parent ? this._nodeMap.get(parent)! : null;
|
||||
|
||||
if (parentNode) {
|
||||
let index = last(parentNode.children)
|
||||
? generateKeyBetween(last(parentNode.children)!.detail.index, null)
|
||||
: 'a0';
|
||||
|
||||
sibling = sibling ?? last(parentNode.children)?.id;
|
||||
const siblingNode =
|
||||
typeof sibling === 'number'
|
||||
? parentNode.children[sibling]
|
||||
: sibling
|
||||
? this._nodeMap.get(sibling)
|
||||
: undefined;
|
||||
const path = siblingNode
|
||||
? this.getPath(siblingNode)
|
||||
: this.getPath(parentNode).concat([0]);
|
||||
const style = this.styleGetter.getNodeStyle(
|
||||
siblingNode ?? parentNode,
|
||||
path
|
||||
);
|
||||
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,100,30]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...style.node,
|
||||
});
|
||||
|
||||
if (siblingNode) {
|
||||
const siblingIndex = parentNode.children.findIndex(
|
||||
val => val.id === sibling
|
||||
);
|
||||
|
||||
index =
|
||||
position === 'after'
|
||||
? generateKeyBetween(
|
||||
siblingNode.detail.index,
|
||||
parentNode.children[siblingIndex + 1]?.detail.index ?? null
|
||||
)
|
||||
: generateKeyBetween(
|
||||
parentNode.children[siblingIndex - 1]?.detail.index ?? null,
|
||||
siblingNode.detail.index
|
||||
);
|
||||
}
|
||||
|
||||
const nodeDetail: NodeDetail = {
|
||||
index,
|
||||
parent: parent!,
|
||||
};
|
||||
|
||||
this.children.set(id, nodeDetail);
|
||||
} else {
|
||||
const rootStyle = this.styleGetter.root;
|
||||
|
||||
id = this.surface.addElement({
|
||||
type,
|
||||
xywh: '[0,0,113,41]',
|
||||
maxWidth: false,
|
||||
...props,
|
||||
...rootStyle,
|
||||
});
|
||||
|
||||
this.children.clear();
|
||||
this.children.set(id, {
|
||||
index: 'a0',
|
||||
});
|
||||
}
|
||||
});
|
||||
this.layout();
|
||||
|
||||
return id!;
|
||||
}
|
||||
|
||||
buildTree() {
|
||||
const mindmapNodeMap = new Map<string, MindmapNode>();
|
||||
const nodesMap = this.children;
|
||||
|
||||
// The element may be removed
|
||||
if (!nodesMap || nodesMap.size === 0) {
|
||||
this._nodeMap = mindmapNodeMap;
|
||||
// @ts-expect-error FIXME: ts error
|
||||
this._tree = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let rootNode: MindmapRoot | undefined;
|
||||
|
||||
nodesMap.forEach((val, id) => {
|
||||
if (!mindmapNodeMap.has(id)) {
|
||||
mindmapNodeMap.set(id, {
|
||||
id,
|
||||
index: val.index,
|
||||
detail: val,
|
||||
element: this.surface.getElementById(id)!,
|
||||
children: [],
|
||||
parent: null,
|
||||
} as MindmapNode);
|
||||
}
|
||||
|
||||
const node = mindmapNodeMap.get(id)!;
|
||||
|
||||
// some node may be already created during
|
||||
// iterating its children
|
||||
if (!node.detail) {
|
||||
node.detail = val;
|
||||
}
|
||||
|
||||
if (!val.parent) {
|
||||
rootNode = node as MindmapRoot;
|
||||
rootNode.left = [];
|
||||
rootNode.right = [];
|
||||
} else {
|
||||
if (!mindmapNodeMap.has(val.parent)) {
|
||||
mindmapNodeMap.set(val.parent, {
|
||||
id: val.parent,
|
||||
detail: nodesMap.get(val.parent)!,
|
||||
parent: null,
|
||||
children: [],
|
||||
element: this.surface.getElementById(val.parent)!,
|
||||
} as MindmapNode);
|
||||
}
|
||||
|
||||
const parent = mindmapNodeMap.get(val.parent)!;
|
||||
parent.children.push(node);
|
||||
node.parent = parent;
|
||||
}
|
||||
});
|
||||
|
||||
mindmapNodeMap.forEach(node => {
|
||||
node.children.sort((a, b) =>
|
||||
a.detail.index === b.detail.index
|
||||
? 0
|
||||
: a.detail.index > b.detail.index
|
||||
? 1
|
||||
: -1
|
||||
);
|
||||
});
|
||||
|
||||
if (!rootNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loops = findInfiniteLoop(rootNode, mindmapNodeMap);
|
||||
|
||||
if (loops.length) {
|
||||
this.surface.doc.withoutTransact(() => {
|
||||
loops.forEach(loop => {
|
||||
if (loop.detached) {
|
||||
loop.chain.forEach(node => {
|
||||
this.children.delete(node.id);
|
||||
});
|
||||
} else {
|
||||
const child = last(loop.chain);
|
||||
|
||||
if (child) {
|
||||
this.children.set(child.id, {
|
||||
index: child.detail.index,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._nodeMap = mindmapNodeMap;
|
||||
this._tree = rootNode;
|
||||
|
||||
if (this.layoutType === LayoutType.BALANCE) {
|
||||
this._cfgBalanceLayoutDir();
|
||||
} else {
|
||||
this._tree[this.layoutType === LayoutType.RIGHT ? 'right' : 'left'] =
|
||||
this._tree.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param subtree The subtree of root, this only take effects when the layout type is BALANCED.
|
||||
* @returns
|
||||
*/
|
||||
getChildNodes(id: string, subtree?: 'left' | 'right') {
|
||||
const node = this._nodeMap.get(id);
|
||||
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (subtree && id === this._tree.id) {
|
||||
return this._tree[subtree];
|
||||
}
|
||||
|
||||
return node.children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all the connectors start from the given node
|
||||
* @param node
|
||||
* @returns
|
||||
*/
|
||||
getConnectors(node: MindmapNode) {
|
||||
if (!this._nodeMap.has(node.id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.detail.collapsed) {
|
||||
const id = `#${node.id}-collapsed`;
|
||||
const layout = this.getLayoutDir(node)!;
|
||||
const connector =
|
||||
this.connectors.get(id) ?? new LocalConnectorElementModel(this.surface);
|
||||
const connectorExist = this.connectors.has(id);
|
||||
const connectorStyle = this.styleGetter.getNodeStyle(
|
||||
node,
|
||||
this.getPath(node).concat([0])
|
||||
).connector;
|
||||
const outdated = this._isConnectorOutdated({
|
||||
connector,
|
||||
from: node,
|
||||
collapsed: true,
|
||||
layout,
|
||||
});
|
||||
|
||||
if (!connectorExist) {
|
||||
connector.id = id;
|
||||
this.connectors.set(id, connector);
|
||||
}
|
||||
|
||||
if (outdated) {
|
||||
const nodeBound = node.element.elementBound;
|
||||
connector.id = id;
|
||||
connector.source = {
|
||||
id: node.id,
|
||||
position: layout === LayoutType.LEFT ? [0, 0.5] : [1, 0.5],
|
||||
};
|
||||
connector.target = {
|
||||
position:
|
||||
layout === LayoutType.LEFT
|
||||
? [nodeBound.x - 6, nodeBound.y + nodeBound.h / 2]
|
||||
: [nodeBound.x + nodeBound.w + 6, nodeBound.y + nodeBound.h / 2],
|
||||
};
|
||||
|
||||
Object.entries(connectorStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
connector[key as unknown] = value;
|
||||
});
|
||||
|
||||
connector.mode = ConnectorMode.Straight;
|
||||
}
|
||||
|
||||
return [{ outdated, connector }];
|
||||
} else {
|
||||
const from = node;
|
||||
return from.children.map(to => {
|
||||
const layout = this.getLayoutDir(to)!;
|
||||
const id = `#${from.id}-${to.id}`;
|
||||
const connectorExist = this.connectors.has(id);
|
||||
const connectorStyle = this.styleGetter.getNodeStyle(
|
||||
to,
|
||||
this.getPath(to)
|
||||
).connector;
|
||||
const connector =
|
||||
this.connectors.get(id) ??
|
||||
new LocalConnectorElementModel(this.surface);
|
||||
const outdated = this._isConnectorOutdated({
|
||||
connector,
|
||||
from,
|
||||
to,
|
||||
layout,
|
||||
});
|
||||
|
||||
if (!connectorExist) {
|
||||
connector.id = id;
|
||||
this.connectors.set(id, connector);
|
||||
}
|
||||
|
||||
if (outdated) {
|
||||
connector.source = {
|
||||
id: from.id,
|
||||
position: layout === LayoutType.RIGHT ? [1, 0.5] : [0, 0.5],
|
||||
};
|
||||
connector.target = {
|
||||
id: to.id,
|
||||
position: layout === LayoutType.RIGHT ? [0, 0.5] : [1, 0.5],
|
||||
};
|
||||
|
||||
Object.entries(connectorStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
connector[key as unknown] = value;
|
||||
});
|
||||
}
|
||||
|
||||
return { outdated, connector };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getLayoutDir(node: string | MindmapNode): LayoutType {
|
||||
node = typeof node === 'string' ? this._nodeMap.get(node)! : node;
|
||||
|
||||
assertType<MindmapNode>(node);
|
||||
|
||||
let current: MindmapNode | null = node;
|
||||
const root = this._tree;
|
||||
|
||||
while (current) {
|
||||
if (current.overriddenDir !== undefined) {
|
||||
return current.overriddenDir;
|
||||
}
|
||||
|
||||
const parent: MindmapNode | null = current.detail.parent
|
||||
? (this._nodeMap.get(current.detail.parent) ?? null)
|
||||
: null;
|
||||
|
||||
if (parent === root) {
|
||||
return (
|
||||
parent.overriddenDir ??
|
||||
(root.left.includes(current)
|
||||
? LayoutType.LEFT
|
||||
: root.right.includes(current)
|
||||
? LayoutType.RIGHT
|
||||
: this.layoutType)
|
||||
);
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return this.layoutType;
|
||||
}
|
||||
|
||||
getNode(id: string) {
|
||||
return this._nodeMap.get(id) ?? null;
|
||||
}
|
||||
|
||||
getParentNode(id: string) {
|
||||
const node = this.children.get(id);
|
||||
|
||||
return node?.parent ? (this._nodeMap.get(node.parent) ?? null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Path is an array of indexes that represent the path from the root node to the target node.
|
||||
* The first element of the array is always 0, which represents the root node.
|
||||
* @param element
|
||||
* @returns
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const path = mindmap.getPath('nodeId');
|
||||
* // [0, 1, 2]
|
||||
* ```
|
||||
*/
|
||||
getPath(element: string | MindmapNode) {
|
||||
let node = this._nodeMap.get(
|
||||
typeof element === 'string' ? element : element.id
|
||||
);
|
||||
|
||||
if (!node) {
|
||||
throw new Error('Node not found');
|
||||
}
|
||||
|
||||
const path: number[] = [];
|
||||
|
||||
while (node && node !== this._tree) {
|
||||
const parent = this._nodeMap.get(node!.detail.parent!);
|
||||
|
||||
path.unshift(parent!.children.indexOf(node!));
|
||||
node = parent;
|
||||
}
|
||||
|
||||
path.unshift(0);
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
getSiblingNode(
|
||||
id: string,
|
||||
direction: 'prev' | 'next' = 'next',
|
||||
/**
|
||||
* The subtree of which that the sibling node belongs to,
|
||||
* this is used when the layout type is BALANCED.
|
||||
*/
|
||||
subtree?: 'left' | 'right'
|
||||
) {
|
||||
const node = this._nodeMap.get(id);
|
||||
|
||||
if (!node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parent = this._nodeMap.get(node.detail.parent!);
|
||||
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const childrenTree =
|
||||
subtree && parent.id === this._tree.id
|
||||
? this._tree[subtree]
|
||||
: parent.children;
|
||||
const idx = childrenTree.indexOf(node);
|
||||
if (idx === -1) {
|
||||
return null;
|
||||
}
|
||||
const siblingIndex = direction === 'next' ? idx + 1 : idx - 1;
|
||||
const sibling = childrenTree[siblingIndex] ?? null;
|
||||
|
||||
return sibling;
|
||||
}
|
||||
|
||||
override includesPoint(x: number, y: number, options: PointTestOptions) {
|
||||
const bound = this.elementBound;
|
||||
|
||||
bound.x -= options.responsePadding?.[0] ?? 0;
|
||||
bound.w += (options.responsePadding?.[0] ?? 0) * 2;
|
||||
bound.y -= options.responsePadding?.[1] ?? 0;
|
||||
bound.h += (options.responsePadding?.[1] ?? 0) * 2;
|
||||
|
||||
return bound.containsPoint([x, y]);
|
||||
}
|
||||
|
||||
layout(
|
||||
_tree: MindmapNode | MindmapRoot = this.tree,
|
||||
_options: {
|
||||
applyStyle?: boolean;
|
||||
layoutType?: LayoutType;
|
||||
calculateTreeBound?: boolean;
|
||||
stashed?: boolean;
|
||||
} = {
|
||||
applyStyle: true,
|
||||
calculateTreeBound: true,
|
||||
stashed: true,
|
||||
}
|
||||
) {
|
||||
// should be implemented by the view
|
||||
// otherwise, it would be just an empty function
|
||||
if (this._layout) {
|
||||
this._layout(_tree, _options);
|
||||
}
|
||||
}
|
||||
|
||||
moveTo(targetXYWH: SerializedXYWH | XYWH) {
|
||||
const { x, y } = this;
|
||||
const targetPos =
|
||||
typeof targetXYWH === 'string' ? deserializeXYWH(targetXYWH) : targetXYWH;
|
||||
const offsetX = targetPos[0] - x;
|
||||
const offsetY = targetPos[1] - y + targetPos[3];
|
||||
|
||||
this.surface.doc.transact(() => {
|
||||
this.childElements.forEach(el => {
|
||||
const deserializedXYWH = deserializeXYWH(el.xywh);
|
||||
|
||||
el.xywh =
|
||||
`[${deserializedXYWH[0] + offsetX},${deserializedXYWH[1] + offsetY},${deserializedXYWH[2]},${deserializedXYWH[3]}]` as SerializedXYWH;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override onCreated(): void {
|
||||
this.buildTree();
|
||||
}
|
||||
|
||||
removeChild(element: GfxModel) {
|
||||
if (!this._nodeMap.has(element.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const surface = this.surface;
|
||||
const removedDescendants: string[] = [];
|
||||
const remove = (node: MindmapNode) => {
|
||||
node.children?.forEach(child => {
|
||||
remove(child);
|
||||
});
|
||||
|
||||
this.children?.delete(node.id);
|
||||
removedDescendants.push(node.id);
|
||||
};
|
||||
|
||||
surface.doc.transact(() => {
|
||||
remove(this._nodeMap.get(element.id)!);
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
removedDescendants.forEach(id => surface.deleteElement(id));
|
||||
});
|
||||
|
||||
// This transaction may not end
|
||||
// force to build the elements
|
||||
this.buildTree();
|
||||
this.requestLayout();
|
||||
}
|
||||
|
||||
protected requestBuildTree() {
|
||||
if (this._queueBuildTree) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._queueBuildTree = true;
|
||||
queueMicrotask(() => {
|
||||
this.buildTree();
|
||||
this._queueBuildTree = false;
|
||||
});
|
||||
}
|
||||
|
||||
requestLayout() {
|
||||
if (!this._queuedLayout) {
|
||||
this._queuedLayout = true;
|
||||
|
||||
queueMicrotask(() => {
|
||||
this.layout();
|
||||
this._queuedLayout = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
override serialize() {
|
||||
const result = super.serialize();
|
||||
return result as SerializedMindmapElement;
|
||||
}
|
||||
|
||||
setLayoutMethod(layoutMethod: MindmapElementModel['layout']) {
|
||||
this._layout = layoutMethod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash mind map node and its children's xywh property
|
||||
* @param node
|
||||
* @returns a function that write back the stashed xywh into yjs
|
||||
*/
|
||||
stashTree(node: MindmapNode | string) {
|
||||
const mindNode = typeof node === 'string' ? this.getNode(node) : node;
|
||||
|
||||
if (!mindNode || this._stashedNode.has(mindNode.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stashed = new Set<BlockSuite.SurfaceElementModel>();
|
||||
const traverse = (node: MindmapNode) => {
|
||||
node.element.stash('xywh');
|
||||
stashed.add(node.element);
|
||||
|
||||
if (node.children.length) {
|
||||
node.children.forEach(child => traverse(child));
|
||||
}
|
||||
};
|
||||
|
||||
traverse(mindNode);
|
||||
|
||||
return () => {
|
||||
this._stashedNode.delete(mindNode.id);
|
||||
stashed.forEach(el => {
|
||||
el.pop('xywh');
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
toggleCollapse(node: MindmapNode, options: { layout?: boolean } = {}) {
|
||||
if (!this._nodeMap.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { layout = false } = options;
|
||||
|
||||
if (node && node.children.length > 0) {
|
||||
const collapsed = node.detail.collapsed ? false : true;
|
||||
const isExpand = !collapsed;
|
||||
|
||||
const changeNodesVisibility = (node: MindmapNode) => {
|
||||
node.element.hidden = collapsed;
|
||||
|
||||
if (isExpand && node.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.children.forEach(child => {
|
||||
changeNodesVisibility(child);
|
||||
});
|
||||
};
|
||||
|
||||
node.children.forEach(changeNodesVisibility);
|
||||
this.surface.doc.transact(() => {
|
||||
this.children.set(node.id, {
|
||||
...node.detail,
|
||||
collapsed,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (layout) {
|
||||
this.requestLayout();
|
||||
}
|
||||
}
|
||||
|
||||
traverse(
|
||||
callback: (node: MindmapNode, parent: MindmapNode | null) => void,
|
||||
root: MindmapNode = this._tree,
|
||||
options: { stopOnCollapse?: boolean } = {}
|
||||
) {
|
||||
const { stopOnCollapse = false } = options;
|
||||
const traverse = (node: MindmapNode, parent: MindmapNode | null) => {
|
||||
callback(node, parent);
|
||||
|
||||
if (stopOnCollapse && node.detail.collapsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
node?.children.forEach(child => {
|
||||
traverse(child, node);
|
||||
});
|
||||
};
|
||||
|
||||
if (root) {
|
||||
traverse(root, null);
|
||||
}
|
||||
}
|
||||
|
||||
@convert((initialValue, instance) => {
|
||||
if (!(initialValue instanceof DocCollection.Y.Map)) {
|
||||
nodeSchema.parse(initialValue);
|
||||
|
||||
assertType<NodeType>(initialValue);
|
||||
|
||||
const map: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
const surface = instance.surface;
|
||||
const doc = surface.doc;
|
||||
const recursive = (
|
||||
node: NodeType,
|
||||
parent: string | null = null,
|
||||
index: string = 'a0'
|
||||
) => {
|
||||
const id = surface.addElement({
|
||||
type: 'shape',
|
||||
text: node.text,
|
||||
xywh: node.xywh ? node.xywh : `[0, 0, 100, 30]`,
|
||||
});
|
||||
|
||||
map.set(id, {
|
||||
index,
|
||||
parent: parent ?? undefined,
|
||||
});
|
||||
|
||||
let curIdx = 'a0';
|
||||
node.children?.forEach(childNode => {
|
||||
recursive(childNode, id, curIdx);
|
||||
curIdx = generateKeyBetween(curIdx, null);
|
||||
});
|
||||
};
|
||||
|
||||
doc.transact(() => {
|
||||
recursive(initialValue);
|
||||
});
|
||||
|
||||
instance.requestBuildTree();
|
||||
instance.requestLayout();
|
||||
return map;
|
||||
} else {
|
||||
instance.requestBuildTree();
|
||||
instance.requestLayout();
|
||||
return initialValue;
|
||||
}
|
||||
})
|
||||
// Use extracted function to avoid playwright test failure
|
||||
// since this model package is imported by playwright
|
||||
@observe(observeChildren)
|
||||
@field()
|
||||
accessor children: Y.Map<NodeDetail> = new DocCollection.Y.Map();
|
||||
|
||||
@watch(watchLayoutType)
|
||||
@field()
|
||||
accessor layoutType: LayoutType = LayoutType.RIGHT;
|
||||
|
||||
@watch(watchStyle)
|
||||
@field()
|
||||
accessor style: MindmapStyle = MindmapStyle.ONE;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace BlockSuite {
|
||||
interface SurfaceGroupLikeModelMap {
|
||||
mindmap: MindmapElementModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import { isEqual, last } from '@blocksuite/global/utils';
|
||||
|
||||
import { ConnectorMode } from '../../consts/connector.js';
|
||||
import { LineColor } from '../../consts/line.js';
|
||||
import { MindmapStyle } from '../../consts/mindmap.js';
|
||||
import { StrokeStyle } from '../../consts/note.js';
|
||||
import { ShapeFillColor } from '../../consts/shape.js';
|
||||
import { FontFamily, FontWeight, TextResizing } from '../../consts/text.js';
|
||||
import type { MindmapNode } from './mindmap.js';
|
||||
|
||||
export type CollapseButton = {
|
||||
width: number;
|
||||
height: number;
|
||||
radius: number;
|
||||
|
||||
filled: boolean;
|
||||
fillColor: string;
|
||||
|
||||
strokeColor: string;
|
||||
strokeWidth: number;
|
||||
};
|
||||
|
||||
export type ExpandButton = CollapseButton & {
|
||||
fontFamily: FontFamily;
|
||||
fontSize: number;
|
||||
fontWeight: FontWeight;
|
||||
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type NodeStyle = {
|
||||
radius: number;
|
||||
|
||||
strokeWidth: number;
|
||||
strokeColor: string;
|
||||
|
||||
textResizing: TextResizing;
|
||||
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
fontWeight: FontWeight;
|
||||
color: string;
|
||||
|
||||
filled: boolean;
|
||||
fillColor: string;
|
||||
|
||||
padding: [number, number];
|
||||
|
||||
shadow?: {
|
||||
blur: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
color: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ConnectorStyle = {
|
||||
strokeStyle: StrokeStyle;
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
|
||||
mode: ConnectorMode;
|
||||
};
|
||||
|
||||
export abstract class MindmapStyleGetter {
|
||||
abstract readonly root: NodeStyle;
|
||||
|
||||
abstract getNodeStyle(
|
||||
node: MindmapNode,
|
||||
path: number[]
|
||||
): {
|
||||
connector: ConnectorStyle;
|
||||
collapseButton: CollapseButton;
|
||||
expandButton: ExpandButton;
|
||||
node: NodeStyle;
|
||||
};
|
||||
}
|
||||
|
||||
export class StyleOne extends MindmapStyleGetter {
|
||||
private _colorOrders = [
|
||||
LineColor.Purple,
|
||||
LineColor.Magenta,
|
||||
LineColor.Orange,
|
||||
LineColor.Yellow,
|
||||
LineColor.Green,
|
||||
'#7ae2d5',
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 4,
|
||||
strokeColor: '#84CFFF',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
padding: [11, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
offsetX: 0,
|
||||
offsetY: 6,
|
||||
blur: 12,
|
||||
color: 'rgba(0, 0, 0, 0.14)',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return this._colorOrders[number % this._colorOrders.length];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const color = this._getColor(path[1] ?? 0);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: color,
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: color,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: color,
|
||||
|
||||
strokeColor: color,
|
||||
strokeWidth: 0,
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
radius: 8,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: color,
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
offsetX: 0,
|
||||
offsetY: 6,
|
||||
blur: 12,
|
||||
color: 'rgba(0, 0, 0, 0.14)',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleOne = new StyleOne();
|
||||
|
||||
export class StyleTwo extends MindmapStyleGetter {
|
||||
private _colorOrders = [
|
||||
ShapeFillColor.Blue,
|
||||
'#7ae2d5',
|
||||
ShapeFillColor.Yellow,
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: '--affine-black',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.Orange,
|
||||
|
||||
padding: [11, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 0,
|
||||
offsetX: 3,
|
||||
offsetY: 3,
|
||||
color: '--affine-black',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return number >= this._colorOrders.length
|
||||
? last(this._colorOrders)!
|
||||
: this._colorOrders[number];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const color = this._getColor(path.length - 2);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: '--affine-black',
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Orthogonal,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: '--affine-black',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 2,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-black',
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: '--affine-black',
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
radius: 3,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 3,
|
||||
strokeColor: '--affine-black',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.SemiBold,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: color,
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 0,
|
||||
offsetX: 3,
|
||||
offsetY: 3,
|
||||
color: '--affine-black',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleTwo = new StyleTwo();
|
||||
|
||||
export class StyleThree extends MindmapStyleGetter {
|
||||
private _strokeColor = [LineColor.Yellow, LineColor.Green, LineColor.Teal];
|
||||
|
||||
readonly root = {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.Yellow,
|
||||
|
||||
padding: [10, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 12,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
color: 'rgba(66, 65, 73, 0.18)',
|
||||
},
|
||||
};
|
||||
|
||||
private _getColor(number: number) {
|
||||
return this._strokeColor[number % this._strokeColor.length];
|
||||
}
|
||||
|
||||
override getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const strokeColor = this._getColor(path.length - 2);
|
||||
|
||||
return {
|
||||
node: {
|
||||
radius: 10,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 2,
|
||||
strokeColor: strokeColor,
|
||||
|
||||
fontFamily: FontFamily.Poppins,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.Medium,
|
||||
color: ShapeFillColor.Black,
|
||||
|
||||
filled: true,
|
||||
fillColor: ShapeFillColor.White,
|
||||
|
||||
padding: [6, 22] as [number, number],
|
||||
|
||||
shadow: {
|
||||
blur: 12,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
color: 'rgba(66, 65, 73, 0.18)',
|
||||
},
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: '#3CBC36',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: '#3CBC36',
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: '#3CBC36',
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '#fff',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke: strokeColor,
|
||||
strokeWidth: 2,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleThree = new StyleThree();
|
||||
|
||||
export class StyleFour extends MindmapStyleGetter {
|
||||
private _colors = [
|
||||
ShapeFillColor.Purple,
|
||||
ShapeFillColor.Magenta,
|
||||
ShapeFillColor.Orange,
|
||||
ShapeFillColor.Yellow,
|
||||
ShapeFillColor.Green,
|
||||
ShapeFillColor.Blue,
|
||||
];
|
||||
|
||||
readonly root = {
|
||||
radius: 0,
|
||||
|
||||
textResizing: TextResizing.AUTO_WIDTH_AND_HEIGHT,
|
||||
|
||||
strokeWidth: 0,
|
||||
strokeColor: 'transparent',
|
||||
|
||||
fontFamily: FontFamily.Kalam,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.Bold,
|
||||
color: '--affine-black',
|
||||
|
||||
filled: true,
|
||||
fillColor: 'transparent',
|
||||
|
||||
padding: [0, 10] as [number, number],
|
||||
};
|
||||
|
||||
private _getColor(order: number) {
|
||||
return this._colors[order % this._colors.length];
|
||||
}
|
||||
|
||||
getNodeStyle(_: MindmapNode, path: number[]) {
|
||||
const stroke = this._getColor(path[1] ?? 0);
|
||||
|
||||
return {
|
||||
connector: {
|
||||
strokeStyle: StrokeStyle.Solid,
|
||||
stroke,
|
||||
strokeWidth: 3,
|
||||
|
||||
mode: ConnectorMode.Curve,
|
||||
},
|
||||
collapseButton: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
radius: 0.5,
|
||||
|
||||
filled: true,
|
||||
fillColor: '--affine-white',
|
||||
|
||||
strokeColor: stroke,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
expandButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
radius: 8,
|
||||
|
||||
filled: true,
|
||||
fillColor: stroke,
|
||||
|
||||
padding: [4, 0],
|
||||
|
||||
strokeColor: stroke,
|
||||
strokeWidth: 0,
|
||||
|
||||
color: '--affine-white',
|
||||
|
||||
fontFamily: FontFamily.Inter,
|
||||
fontWeight: FontWeight.Bold,
|
||||
fontSize: 15,
|
||||
},
|
||||
node: {
|
||||
...this.root,
|
||||
|
||||
fontSize: 18,
|
||||
padding: [1.5, 10] as [number, number],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
export const styleFour = new StyleFour();
|
||||
|
||||
export const mindmapStyleGetters: Record<MindmapStyle, MindmapStyleGetter> = {
|
||||
[MindmapStyle.ONE]: styleOne,
|
||||
[MindmapStyle.TWO]: styleTwo,
|
||||
[MindmapStyle.THREE]: styleThree,
|
||||
[MindmapStyle.FOUR]: styleFour,
|
||||
};
|
||||
|
||||
export const applyNodeStyle = (node: MindmapNode, nodeStyle: NodeStyle) => {
|
||||
Object.entries(nodeStyle).forEach(([key, value]) => {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
if (!isEqual(node.element[key], value)) {
|
||||
// @ts-expect-error FIXME: ts error
|
||||
node.element[key] = value;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { MindmapNode } from './mindmap.js';
|
||||
|
||||
export function findInfiniteLoop(
|
||||
root: MindmapNode,
|
||||
nodeMap: Map<string, MindmapNode>
|
||||
) {
|
||||
const visited = new Set<string>();
|
||||
const loop: {
|
||||
detached: boolean;
|
||||
chain: MindmapNode[];
|
||||
}[] = [];
|
||||
|
||||
const traverse = (
|
||||
node: MindmapNode,
|
||||
traverseChain: MindmapNode[] = [],
|
||||
detached = false
|
||||
) => {
|
||||
if (visited.has(node.id)) {
|
||||
loop.push({
|
||||
detached,
|
||||
chain: traverseChain,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
visited.add(node.id);
|
||||
|
||||
traverseChain.push(node);
|
||||
|
||||
node.children.forEach(child =>
|
||||
traverse(child, traverseChain.slice(), detached)
|
||||
);
|
||||
};
|
||||
|
||||
traverse(root);
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (visited.has(node.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
traverse(node, [], true);
|
||||
});
|
||||
|
||||
return loop;
|
||||
}
|
||||
Reference in New Issue
Block a user