feat: scaffold mindmap core and layout engine

This commit is contained in:
liaibo
2025-11-29 19:07:18 +08:00
parent 140bce9768
commit 3305944db4
15 changed files with 905 additions and 0 deletions
@@ -0,0 +1,112 @@
import { useMemo, useRef } from 'react';
import { MindLayoutEngine } from '@/lib/mindmap/layout/MindLayoutEngine';
import { MindmapTree, LayoutName, LayoutResult } from '@/lib/mindmap/types';
import { useMindmapStore } from '@/store/useMindmapStore';
import styles from './mindmap-canvas.module.css';
export interface MindmapCanvasProps {
trees: MindmapTree | MindmapTree[];
layout?: LayoutName;
className?: string;
}
const engine = new MindLayoutEngine();
export function MindmapCanvas({ trees, layout = 'logical', className }: MindmapCanvasProps) {
const [state, setState] = useMindmapStore((s) => s);
const ref = useRef<HTMLDivElement | null>(null);
const treeList = Array.isArray(trees) ? trees : [trees];
const results = useMemo(() => {
return treeList.map((tree) => engine.compute(tree, layout));
}, [treeList, layout]);
const combined: LayoutResult = results.reduce(
(acc, result, index) => {
const offsetX = index * (result.bounds.width + 120);
acc.nodes.push(
...result.nodes.map((node) => ({
...node,
position: {
x: node.position.x + offsetX,
y: node.position.y,
},
})),
);
acc.edges.push(...result.edges);
acc.bounds.width = Math.max(acc.bounds.width, offsetX + result.bounds.width);
acc.bounds.height = Math.max(acc.bounds.height, result.bounds.height);
return acc;
},
{ nodes: [], edges: [], bounds: { width: 0, height: 0 } } as LayoutResult,
);
const handleWheel = (event: React.WheelEvent) => {
event.preventDefault();
setState((draft) => {
const nextScale = Math.min(2, Math.max(0.2, draft.scale - event.deltaY * 0.0015));
draft.scale = nextScale;
});
};
return (
<div
ref={ref}
onWheel={handleWheel}
className={[styles.canvas, className].filter(Boolean).join(' ')}
style={{ '--mindmap-scale': state.scale } as React.CSSProperties}
>
<div
className={styles.scene}
style={{
width: combined.bounds.width,
height: combined.bounds.height,
transform: `scale(${state.scale}) translate(${state.translate.x}px, ${state.translate.y}px)`,
}}
>
{combined.nodes.map((node) => (
<div
key={node.id}
className={[
styles.node,
state.activeNodeIds.includes(node.id) ? styles.nodeActive : '',
]
.filter(Boolean)
.join(' ')}
style={{
left: node.position.x,
top: node.position.y,
}}
>
<div className={styles.title}>{node.data.title}</div>
{node.data.tags?.length ? (
<div className={styles.tags}>
{node.data.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
) : null}
</div>
))}
{combined.edges.map((edge) => {
const source = combined.nodes.find((n) => n.id === edge.source);
const target = combined.nodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
return (
<svg key={edge.id} className={styles.edge}>
<line
x1={source.position.x + 120}
y1={source.position.y + 32}
x2={target.position.x}
y2={target.position.y + 32}
stroke="var(--mindmap-edge-color)"
strokeWidth={2}
/>
</svg>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,61 @@
.canvas {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
background: radial-gradient(circle at top, #f8fafc, #eef2ff);
}
.scene {
position: relative;
transform-origin: 0 0;
}
.node {
position: absolute;
min-width: 180px;
max-width: 320px;
padding: 12px 16px;
border-radius: 20px;
background: #fff;
border: 2px solid #e2e8f0;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
transition: border-color 0.2s ease;
}
.nodeActive {
border-color: #3b82f6;
box-shadow: 0 15px 35px rgba(59, 130, 246, 0.3);
}
.title {
font-weight: 600;
font-size: 16px;
color: #0f172a;
}
.tags {
margin-top: 6px;
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tags span {
background: #e0f2fe;
color: #0369a1;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
}
.edge {
position: absolute;
overflow: visible;
pointer-events: none;
}
:root {
--mindmap-edge-color: #cbd5f5;
}
@@ -0,0 +1,58 @@
import { useEffect } from 'react';
import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus';
type ShortcutHandler = () => void;
const shortcutMap = new Map<string, ShortcutHandler[]>([
['Enter', [() => mindmapCommandBus.emit('INSERT_CHILD_NODE', { parentId: '' })]],
['Delete', [() => mindmapCommandBus.emit('REMOVE_NODE', { nodeId: '' })]],
]);
let activeInstance: string | null = null;
export interface MindmapShortcutControllerProps {
instanceId: string;
enable?: boolean;
}
export function MindmapShortcutController({ instanceId, enable = true }: MindmapShortcutControllerProps) {
useEffect(() => {
if (!enable) return;
const onKeyDownCapture = (event: KeyboardEvent) => {
if (activeInstance && activeInstance !== instanceId) return;
if (!activeInstance) activeInstance = instanceId;
const combo = getKeyCombo(event);
const handlers = shortcutMap.get(combo);
if (!handlers) return;
event.stopPropagation();
event.preventDefault();
handlers.forEach((handler) => handler());
};
document.addEventListener('keydown', onKeyDownCapture, true);
return () => {
document.removeEventListener('keydown', onKeyDownCapture, true);
if (activeInstance === instanceId) {
activeInstance = null;
}
};
}, [instanceId, enable]);
return null;
}
export function registerShortcut(combo: string, handler: ShortcutHandler) {
const existing = shortcutMap.get(combo) ?? [];
existing.push(handler);
shortcutMap.set(combo, existing);
}
function getKeyCombo(event: KeyboardEvent) {
const parts: string[] = [];
if (event.ctrlKey || event.metaKey) parts.push('Control');
if (event.altKey) parts.push('Alt');
if (event.shiftKey) parts.push('Shift');
parts.push(event.key.length === 1 ? event.key.toUpperCase() : event.key);
return parts.join('+');
}
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import styles from './mindmap-text-editor.module.css';
export interface MindmapTextEditorProps {
value: string;
onChange(value: string): void;
onBlur?(): void;
}
export function MindmapTextEditor({ value, onChange, onBlur }: MindmapTextEditorProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ref.current && ref.current.innerText !== value) {
ref.current.innerText = value;
}
}, [value]);
const handleInput = () => {
if (!ref.current) return;
onChange(ref.current.innerText);
};
return (
<div
ref={ref}
className={styles.editor}
contentEditable
suppressContentEditableWarning
onInput={handleInput}
onBlur={onBlur}
/>
);
}
@@ -0,0 +1,13 @@
.editor {
min-width: 160px;
min-height: 48px;
outline: none;
border-radius: 16px;
padding: 8px 12px;
border: 1px solid rgba(148, 163, 184, 0.6);
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
font-size: 14px;
line-height: 1.4;
}
@@ -0,0 +1,17 @@
import { useState, useCallback } from 'react';
import { mindmapCommandBus } from '@/lib/mindmap/command/MindmapCommandBus';
export function useMindmapTextEdit(nodeId: string) {
const [value, setValue] = useState('');
const handleChange = useCallback(
(next: string) => {
setValue(next);
mindmapCommandBus.emit('SET_NODE_TEXT', { nodeId, text: next });
},
[nodeId],
);
return { value, setValue, handleChange };
}
@@ -0,0 +1,34 @@
type Handler<T = unknown> = (payload: T) => void;
export interface CommandMap {
INSERT_NODE: { parentId?: string };
INSERT_CHILD_NODE: { parentId: string };
REMOVE_NODE: { nodeId: string };
SET_NODE_TEXT: { nodeId: string; text: string };
SET_NODE_LINK: { nodeId: string; link: unknown };
}
export type CommandName = keyof CommandMap;
export class MindmapCommandBus {
private handlers: { [K in CommandName]?: Set<Handler<CommandMap[K]>> } = {};
on<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
if (!this.handlers[command]) {
this.handlers[command] = new Set();
}
this.handlers[command]!.add(handler);
return () => this.off(command, handler);
}
off<K extends CommandName>(command: K, handler: Handler<CommandMap[K]>) {
this.handlers[command]?.delete(handler);
}
emit<K extends CommandName>(command: K, payload: CommandMap[K]) {
this.handlers[command]?.forEach((handler) => handler(payload));
}
}
export const mindmapCommandBus = new MindmapCommandBus();
@@ -0,0 +1,37 @@
import {
LayoutEngine,
LayoutOptions,
LayoutResult,
LayoutName,
MindmapTree,
} from '@/lib/mindmap/types';
import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure';
import { MindLayout } from '@/lib/mindmap/layout/engines/mindMap';
type EngineRegistry = Record<LayoutName, LayoutEngine>;
export class MindLayoutEngine {
private engines: EngineRegistry;
private defaultOptions: LayoutOptions;
constructor(options: LayoutOptions = {}) {
this.defaultOptions = options;
this.engines = {
logical: new LogicalStructureLayout(),
mind: new MindLayout(),
radial: new LogicalStructureLayout(),
timeline: new LogicalStructureLayout(),
};
}
register(name: LayoutName, engine: LayoutEngine) {
this.engines[name] = engine;
}
compute(tree: MindmapTree, layout: LayoutName = 'logical', options?: LayoutOptions): LayoutResult {
const engine = this.engines[layout] ?? this.engines.logical;
const mergedOptions = { ...this.defaultOptions, ...options };
return engine.compute(tree, mergedOptions);
}
}
@@ -0,0 +1,62 @@
import {
LayoutEngine,
LayoutNode,
LayoutOptions,
LayoutResult,
MindmapNode,
MindmapTree,
} from '@/lib/mindmap/types';
interface PositionedNode {
node: MindmapNode;
depth: number;
index: number;
}
export class LogicalStructureLayout implements LayoutEngine {
readonly name = 'logical' as const;
compute(tree: MindmapTree, options: LayoutOptions = {}): LayoutResult {
const horizontalSpacing = options.horizontalSpacing ?? 280;
const verticalSpacing = options.verticalSpacing ?? 96;
const positioned: PositionedNode[] = [];
walk(tree, 0, positioned);
const nodes: LayoutNode[] = positioned.map((item) => ({
id: item.node.id,
parentId: item.node.parentId,
depth: item.depth,
data: item.node.data,
position: {
x: item.depth * horizontalSpacing,
y: item.index * verticalSpacing,
},
}));
const edges = nodes
.filter((n) => n.parentId)
.map((n) => ({
id: `${n.parentId}-${n.id}`,
source: n.parentId!,
target: n.id,
}));
const bounds = {
width: Math.max(...nodes.map((n) => n.position.x), 0) + horizontalSpacing,
height:
Math.max(...nodes.map((n) => n.position.y), 0) + verticalSpacing,
};
return { nodes, edges, bounds };
}
}
function walk(node: MindmapNode, depth: number, acc: PositionedNode[], nextIndex = { value: 0 }) {
acc.push({ node, depth, index: nextIndex.value++ });
node.children?.forEach((child) => {
child.parentId = node.id;
walk(child, depth + 1, acc, nextIndex);
});
}
+24
View File
@@ -0,0 +1,24 @@
import {
LayoutEngine,
LayoutOptions,
LayoutResult,
MindmapTree,
} from '@/lib/mindmap/types';
import { LogicalStructureLayout } from '@/lib/mindmap/layout/engines/logicalStructure';
/**
* Mind map layout currently reuses the logical structure strategy but keeps a
* distinct class so that we can introduce bezier/left-right balancing later.
*/
export class MindLayout implements LayoutEngine {
readonly name = 'mind' as const;
private delegate = new LogicalStructureLayout();
compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult {
return this.delegate.compute(tree, {
horizontalSpacing: options?.horizontalSpacing ?? 320,
verticalSpacing: options?.verticalSpacing ?? 72,
});
}
}
+88
View File
@@ -0,0 +1,88 @@
import {
MindmapLink,
MindmapLinkDocument,
MindmapLinkMindNode,
MindmapLinkUrl,
} from '@/lib/mindmap/types';
const MINDMAP_PROTOCOL = 'mind://';
export interface ParseLinkResult {
link: MindmapLink | null;
raw: string;
}
export function parseLink(input: string): ParseLinkResult {
const trimmed = input.trim();
if (!trimmed) {
return { link: null, raw: input };
}
return (
parseMindmapProtocol(trimmed) ??
parseJsonLink(trimmed) ??
parsePlainUrl(trimmed) ?? { link: null, raw: input }
);
}
function parseMindmapProtocol(text: string): ParseLinkResult | null {
if (!text.startsWith(MINDMAP_PROTOCOL)) return null;
const fragment = text.slice(MINDMAP_PROTOCOL.length);
const [mindmapId, nodeId] = fragment.split('/');
if (!mindmapId || !nodeId) return null;
const link: MindmapLinkMindNode = {
type: 'mindmap-node',
mindmapId,
nodeId,
};
return { link, raw: text };
}
function parseJsonLink(text: string): ParseLinkResult | null {
try {
const payload = JSON.parse(text);
if (payload?.type === 'document' && typeof payload.documentId === 'string') {
const link: MindmapLinkDocument = {
type: 'document',
documentId: payload.documentId,
title: payload.title,
};
return { link, raw: text };
}
return null;
} catch {
return null;
}
}
function parsePlainUrl(text: string): ParseLinkResult | null {
try {
const url = new URL(text);
const link: MindmapLinkUrl = {
type: 'url',
url: url.toString(),
title: url.hostname,
};
return { link, raw: text };
} catch {
return null;
}
}
export function serializeLink(link: MindmapLink | null): string {
if (!link) return '';
switch (link.type) {
case 'document':
return JSON.stringify({
type: 'document',
documentId: link.documentId,
title: link.title,
});
case 'mindmap-node':
return `${MINDMAP_PROTOCOL}${link.mindmapId}/${link.nodeId}`;
case 'url':
return link.url;
default:
return '';
}
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Core shared mindmap types used across the Stage S1 implementation.
* These types intentionally avoid external library dependencies so that
* they can be consumed both on the server (Next.js route handlers) and
* the client (React components).
*/
export type MindmapIdentifier = string;
export type LayoutName =
| 'logical'
| 'mind'
| 'radial'
| 'timeline';
export interface MindmapNodeData {
title: string;
richText?: string;
icon?: string;
tags?: string[];
link?: MindmapLink | null;
attachments?: MindmapAttachment[];
collapsed?: boolean;
[key: string]: unknown;
}
export interface MindmapNode {
id: MindmapIdentifier;
parentId?: MindmapIdentifier;
children?: MindmapNode[];
data: MindmapNodeData;
}
export type MindmapTree = MindmapNode;
export interface MindmapLinkBlock {
type: 'block';
blockId: string;
title?: string;
}
export interface MindmapLinkDocument {
type: 'document';
documentId: string;
title?: string;
}
export interface MindmapLinkMindNode {
type: 'mindmap-node';
mindmapId: string;
nodeId: string;
title?: string;
}
export interface MindmapLinkPdf {
type: 'pdf';
path: string;
id: string;
title?: string;
imageUrl?: string;
}
export interface MindmapLinkUrl {
type: 'url';
url: string;
title?: string;
}
export type MindmapLink =
| MindmapLinkBlock
| MindmapLinkDocument
| MindmapLinkMindNode
| MindmapLinkPdf
| MindmapLinkUrl;
export interface MindmapAttachment {
id: string;
name: string;
url: string;
}
export interface LayoutNode {
id: MindmapIdentifier;
parentId?: MindmapIdentifier;
position: { x: number; y: number };
depth: number;
data: MindmapNodeData;
}
export interface LayoutEdge {
id: string;
source: MindmapIdentifier;
target: MindmapIdentifier;
}
export interface LayoutResult {
nodes: LayoutNode[];
edges: LayoutEdge[];
bounds: { width: number; height: number };
}
export interface LayoutOptions {
nodeWidth?: number;
nodeHeight?: number;
horizontalSpacing?: number;
verticalSpacing?: number;
}
export interface LayoutEngine {
readonly name: LayoutName;
compute(tree: MindmapTree, options?: LayoutOptions): LayoutResult;
}
+44
View File
@@ -0,0 +1,44 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
export interface MindmapState {
scale: number;
translate: { x: number; y: number };
activeNodeIds: string[];
multiRoot: boolean;
}
type Setter<T> = (updater: (state: T) => void) => void;
function createStore(initialState: MindmapState) {
let state = initialState;
const listeners = new Set<Listener>();
const setState: Setter<MindmapState> = (updater) => {
updater(state);
listeners.forEach((listener) => listener());
};
const getSnapshot = () => state;
const subscribe = (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
return { setState, getSnapshot, subscribe };
}
const store = createStore({
scale: 1,
translate: { x: 0, y: 0 },
activeNodeIds: [],
multiRoot: false,
});
export function useMindmapStore<T>(selector: (state: MindmapState) => T): [T, Setter<MindmapState>] {
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
return [selector(snapshot), store.setState];
}