feat: scaffold mindmap core and layout engine
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user