@ankorar/nodex 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -0
- package/package.json +54 -0
- package/src/components/mindMap/Background.tsx +39 -0
- package/src/components/mindMap/Board.tsx +159 -0
- package/src/components/mindMap/CentalNode.tsx +121 -0
- package/src/components/mindMap/DefaultNode.tsx +205 -0
- package/src/components/mindMap/Header.tsx +247 -0
- package/src/components/mindMap/ImageNode.tsx +345 -0
- package/src/components/mindMap/KeyboardHelpDialog.tsx +108 -0
- package/src/components/mindMap/MineMap.tsx +237 -0
- package/src/components/mindMap/NodeStylePopover.tsx +486 -0
- package/src/components/mindMap/Nodes.tsx +113 -0
- package/src/components/mindMap/Nodex.tsx +65 -0
- package/src/components/mindMap/SaveStatusIndicator.tsx +61 -0
- package/src/components/mindMap/Segments.tsx +270 -0
- package/src/components/mindMap/ZenCard.tsx +41 -0
- package/src/components/ui/dialog.tsx +141 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +192 -0
- package/src/components/ui/toggle-group.tsx +83 -0
- package/src/components/ui/toggle.tsx +45 -0
- package/src/config/rootKeyBinds.ts +191 -0
- package/src/config/shortCuts.ts +28 -0
- package/src/contexts/MindMapNodeEditorContext.tsx +47 -0
- package/src/handlers/rootKeyBinds/handleAltEKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltHKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltWKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleAltZKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleArrowHorizontalRootKeyBind.ts +46 -0
- package/src/handlers/rootKeyBinds/handleArrowVerticalRootKeyBind.ts +44 -0
- package/src/handlers/rootKeyBinds/handleBackEspaceKeyBind.ts +12 -0
- package/src/handlers/rootKeyBinds/handleERootKeyBind.ts +16 -0
- package/src/handlers/rootKeyBinds/handleEnterRootKeyBind.ts +35 -0
- package/src/handlers/rootKeyBinds/handleEscapeKeyBind.ts +24 -0
- package/src/handlers/rootKeyBinds/handleEspaceKeyBind.ts +11 -0
- package/src/handlers/rootKeyBinds/handleMoveByWorldKeyBind.ts +6 -0
- package/src/handlers/rootKeyBinds/handleRedoRootKeyBind.ts +23 -0
- package/src/handlers/rootKeyBinds/handleTabRootKeyBind.ts +49 -0
- package/src/handlers/rootKeyBinds/handleTransformNodeKeyBind.ts +39 -0
- package/src/handlers/rootKeyBinds/handleUndoRootKeyBind.ts +23 -0
- package/src/handlers/rootKeyBinds/handleZoonByKeyBind.ts +31 -0
- package/src/helpers/centerNode.ts +19 -0
- package/src/helpers/getNodeSide.ts +16 -0
- package/src/hooks/mindMap/useHelpers.tsx +9 -0
- package/src/hooks/mindMap/useMindMapDebounce.ts +47 -0
- package/src/hooks/mindMap/useMindMapHistoryDebounce.ts +69 -0
- package/src/hooks/mindMap/useMindMapNode.tsx +203 -0
- package/src/hooks/mindMap/useMindMapNodeEditor.ts +91 -0
- package/src/hooks/mindMap/useMindMapNodeMouseHandlers.ts +24 -0
- package/src/hooks/mindMap/useRootKeyBindHandlers.ts +49 -0
- package/src/hooks/mindMap/useRootMouseHandlers.ts +124 -0
- package/src/hooks/mindMap/useUpdateCenter.ts +54 -0
- package/src/index.ts +76 -0
- package/src/lib/utils.ts +6 -0
- package/src/state/mindMap.ts +793 -0
- package/src/state/mindMapHistory.ts +96 -0
- package/src/styles.input.css +95 -0
- package/src/utils/exportMindMapAsHighQualityImage.ts +327 -0
- package/src/utils/exportMindMapAsMarkdown.ts +102 -0
- package/src/utils/exportMindMapAsPdf.ts +241 -0
- package/src/utils/getMindMapPreviewDataUrl.ts +60 -0
- package/styles.css +2 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
import type { MindMapNode, MindMapPos } from "./mindMap";
|
|
4
|
+
|
|
5
|
+
export type MindMapSnapshot = {
|
|
6
|
+
nodes: MindMapNode[];
|
|
7
|
+
selectedNodeId: string | null;
|
|
8
|
+
editingNodeId: string | null;
|
|
9
|
+
offset: MindMapPos;
|
|
10
|
+
scale: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type MindMapHistoryState = {
|
|
14
|
+
past: MindMapSnapshot[];
|
|
15
|
+
future: MindMapSnapshot[];
|
|
16
|
+
resetVersion: number;
|
|
17
|
+
pushSnapshot: (snapshot: MindMapSnapshot) => void;
|
|
18
|
+
undo: (
|
|
19
|
+
current: MindMapSnapshot,
|
|
20
|
+
apply: (snapshot: MindMapSnapshot) => void
|
|
21
|
+
) => void;
|
|
22
|
+
redo: (
|
|
23
|
+
current: MindMapSnapshot,
|
|
24
|
+
apply: (snapshot: MindMapSnapshot) => void
|
|
25
|
+
) => void;
|
|
26
|
+
clear: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HISTORY_LIMIT = 100;
|
|
30
|
+
|
|
31
|
+
const cloneNodes = (nodes: MindMapNode[]): MindMapNode[] =>
|
|
32
|
+
nodes.map((node) => ({
|
|
33
|
+
...node,
|
|
34
|
+
pos: { ...node.pos },
|
|
35
|
+
style: { ...node.style, padding: { ...node.style.padding } },
|
|
36
|
+
childrens: cloneNodes(node.childrens),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
export const createMindMapSnapshot = (
|
|
40
|
+
source: MindMapSnapshot
|
|
41
|
+
): MindMapSnapshot => ({
|
|
42
|
+
nodes: cloneNodes(source.nodes),
|
|
43
|
+
selectedNodeId: source.selectedNodeId,
|
|
44
|
+
editingNodeId: source.editingNodeId,
|
|
45
|
+
offset: { ...source.offset },
|
|
46
|
+
scale: source.scale,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const cloneSnapshot = (snapshot: MindMapSnapshot): MindMapSnapshot =>
|
|
50
|
+
createMindMapSnapshot(snapshot);
|
|
51
|
+
|
|
52
|
+
export const useMindMapHistory = create<MindMapHistoryState>((set, get) => ({
|
|
53
|
+
past: [],
|
|
54
|
+
future: [],
|
|
55
|
+
resetVersion: 0,
|
|
56
|
+
pushSnapshot: (snapshot) => {
|
|
57
|
+
set((state) => ({
|
|
58
|
+
past: [...state.past, snapshot].slice(-HISTORY_LIMIT),
|
|
59
|
+
future: [],
|
|
60
|
+
}));
|
|
61
|
+
},
|
|
62
|
+
undo: (current, apply) => {
|
|
63
|
+
const { past, future } = get();
|
|
64
|
+
if (past.length === 0) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const snapshot = past[past.length - 1];
|
|
68
|
+
apply(cloneSnapshot(snapshot));
|
|
69
|
+
set({
|
|
70
|
+
past: past.slice(0, -1),
|
|
71
|
+
future: [current, ...future],
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
redo: (current, apply) => {
|
|
75
|
+
const { past, future } = get();
|
|
76
|
+
if (future.length === 0) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const snapshot = future[0];
|
|
80
|
+
apply(cloneSnapshot(snapshot));
|
|
81
|
+
set({
|
|
82
|
+
past: [...past, current].slice(-HISTORY_LIMIT),
|
|
83
|
+
future: future.slice(1),
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
clear: () =>
|
|
87
|
+
set((state) => ({
|
|
88
|
+
past: [],
|
|
89
|
+
future: [],
|
|
90
|
+
resetVersion: state.resetVersion + 1,
|
|
91
|
+
})),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
export function clearMindMapHistory() {
|
|
95
|
+
useMindMapHistory.getState().clear();
|
|
96
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
@import "tw-animate-css";
|
|
3
|
+
|
|
4
|
+
@source "./**/*.{ts,tsx}";
|
|
5
|
+
|
|
6
|
+
@custom-variant dark (&:is(.dark *));
|
|
7
|
+
|
|
8
|
+
:root {
|
|
9
|
+
--background: oklch(1 0 0);
|
|
10
|
+
--foreground: oklch(0.145 0 0);
|
|
11
|
+
--card: oklch(1 0 0);
|
|
12
|
+
--card-foreground: oklch(0.145 0 0);
|
|
13
|
+
--popover: oklch(1 0 0);
|
|
14
|
+
--popover-foreground: oklch(0.145 0 0);
|
|
15
|
+
--primary: oklch(0.205 0 0);
|
|
16
|
+
--primary-foreground: oklch(0.985 0 0);
|
|
17
|
+
--secondary: oklch(0.97 0 0);
|
|
18
|
+
--secondary-foreground: oklch(0.205 0 0);
|
|
19
|
+
--muted: oklch(0.97 0 0);
|
|
20
|
+
--muted-foreground: oklch(0.556 0 0);
|
|
21
|
+
--accent: oklch(0.97 0 0);
|
|
22
|
+
--accent-foreground: oklch(0.205 0 0);
|
|
23
|
+
--destructive: oklch(0.577 0.245 27.325);
|
|
24
|
+
--destructive-foreground: oklch(0.577 0.245 27.325);
|
|
25
|
+
--border: oklch(0.922 0 0);
|
|
26
|
+
--input: oklch(0.922 0 0);
|
|
27
|
+
--ring: oklch(0.708 0 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.dark {
|
|
31
|
+
--background: oklch(0.145 0 0);
|
|
32
|
+
--foreground: oklch(0.985 0 0);
|
|
33
|
+
--card: oklch(0.145 0 0);
|
|
34
|
+
--card-foreground: oklch(0.985 0 0);
|
|
35
|
+
--popover: oklch(0.145 0 0);
|
|
36
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
37
|
+
--primary: oklch(0.985 0 0);
|
|
38
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
39
|
+
--secondary: oklch(0.269 0 0);
|
|
40
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
41
|
+
--muted: oklch(0.269 0 0);
|
|
42
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
43
|
+
--accent: oklch(0.269 0 0);
|
|
44
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
45
|
+
--destructive: oklch(0.396 0.141 25.723);
|
|
46
|
+
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
47
|
+
--border: oklch(0.269 0 0);
|
|
48
|
+
--input: oklch(0.269 0 0);
|
|
49
|
+
--ring: oklch(0.439 0 0);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@theme inline {
|
|
53
|
+
--color-background: var(--background);
|
|
54
|
+
--color-foreground: var(--foreground);
|
|
55
|
+
--color-card: var(--card);
|
|
56
|
+
--color-card-foreground: var(--card-foreground);
|
|
57
|
+
--color-popover: var(--popover);
|
|
58
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
59
|
+
--color-primary: var(--primary);
|
|
60
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
61
|
+
--color-secondary: var(--secondary);
|
|
62
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
63
|
+
--color-muted: var(--muted);
|
|
64
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
65
|
+
--color-accent: var(--accent);
|
|
66
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
67
|
+
--color-destructive: var(--destructive);
|
|
68
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
69
|
+
--color-border: var(--border);
|
|
70
|
+
--color-input: var(--input);
|
|
71
|
+
--color-ring: var(--ring);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@layer utilities {
|
|
75
|
+
.scrollbar {
|
|
76
|
+
scrollbar-width: thin;
|
|
77
|
+
scrollbar-color: rgb(148 163 184) transparent;
|
|
78
|
+
}
|
|
79
|
+
.scrollbar::-webkit-scrollbar {
|
|
80
|
+
width: 10px;
|
|
81
|
+
height: 10px;
|
|
82
|
+
}
|
|
83
|
+
.scrollbar::-webkit-scrollbar-track {
|
|
84
|
+
background: transparent;
|
|
85
|
+
}
|
|
86
|
+
.scrollbar::-webkit-scrollbar-thumb {
|
|
87
|
+
background-color: rgb(148 163 184);
|
|
88
|
+
border-radius: 9999px;
|
|
89
|
+
border: 2px solid transparent;
|
|
90
|
+
background-clip: padding-box;
|
|
91
|
+
}
|
|
92
|
+
.scrollbar::-webkit-scrollbar-thumb:hover {
|
|
93
|
+
background-color: rgb(100 116 139);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { MindMapNode } from "../state/mindMap";
|
|
2
|
+
import {
|
|
3
|
+
getNodesBounds,
|
|
4
|
+
getNodeWrapper,
|
|
5
|
+
getSegmentLines,
|
|
6
|
+
type SegmentLine,
|
|
7
|
+
} from "../components/mindMap/Segments";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_FONT_FAMILY = '"Geist", "Inter", system-ui, sans-serif';
|
|
10
|
+
const SEGMENT_STROKE_WIDTH = 6;
|
|
11
|
+
const LINE_HEIGHT_FACTOR = 1.15;
|
|
12
|
+
|
|
13
|
+
/** Pixel scale factor for export (2–4). Higher = larger image and sharper text when zooming. */
|
|
14
|
+
export const HIGH_QUALITY_EXPORT_SCALE = 3;
|
|
15
|
+
|
|
16
|
+
/** Padding around the map content in the exported image (in logical pixels). */
|
|
17
|
+
const EXPORT_PADDING = 48;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Browser canvas size limits (~16k per side / ~268M pixels in Chrome). We cap dimensions
|
|
21
|
+
* so large maps still export: we reduce scale automatically when needed.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_CANVAS_DIMENSION = 16384;
|
|
24
|
+
|
|
25
|
+
/** Collects all visible nodes from the tree (depth-first). */
|
|
26
|
+
function getFlatVisibleNodes(nodes: MindMapNode[]): MindMapNode[] {
|
|
27
|
+
const out: MindMapNode[] = [];
|
|
28
|
+
function walk(list: MindMapNode[]) {
|
|
29
|
+
for (const node of list) {
|
|
30
|
+
if (!node.isVisible) continue;
|
|
31
|
+
out.push(node);
|
|
32
|
+
walk(node.childrens);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
walk(nodes);
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getNodeFontWeight(node: MindMapNode): number {
|
|
40
|
+
if (!node.style.isBold) return 400;
|
|
41
|
+
return node.type === "central" ? 700 : 600;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Splits node text into lines that fit within maxWidth using canvas measureText.
|
|
46
|
+
*/
|
|
47
|
+
function getTextLines(
|
|
48
|
+
ctx: CanvasRenderingContext2D,
|
|
49
|
+
text: string,
|
|
50
|
+
maxWidth: number,
|
|
51
|
+
fontSize: number,
|
|
52
|
+
fontWeight: number,
|
|
53
|
+
isItalic: boolean,
|
|
54
|
+
): string[] {
|
|
55
|
+
if (!text.trim()) return [""];
|
|
56
|
+
const font = `${isItalic ? "italic" : "normal"} ${fontWeight} ${fontSize}px ${DEFAULT_FONT_FAMILY}`;
|
|
57
|
+
ctx.font = font;
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
const paragraphs = text.split("\n");
|
|
60
|
+
for (const para of paragraphs) {
|
|
61
|
+
const words = para.split(/\s+/).filter(Boolean);
|
|
62
|
+
if (words.length === 0) {
|
|
63
|
+
lines.push("");
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let current = words[0];
|
|
67
|
+
for (let i = 1; i < words.length; i++) {
|
|
68
|
+
const next = `${current} ${words[i]}`;
|
|
69
|
+
const m = ctx.measureText(next);
|
|
70
|
+
const w = m.actualBoundingBoxRight !== undefined
|
|
71
|
+
? m.actualBoundingBoxLeft + m.actualBoundingBoxRight
|
|
72
|
+
: m.width;
|
|
73
|
+
if (w <= maxWidth) {
|
|
74
|
+
current = next;
|
|
75
|
+
} else {
|
|
76
|
+
lines.push(current);
|
|
77
|
+
current = words[i];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (current) lines.push(current);
|
|
81
|
+
}
|
|
82
|
+
return lines.length ? lines : [""];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function drawSegment(
|
|
86
|
+
ctx: CanvasRenderingContext2D,
|
|
87
|
+
seg: SegmentLine,
|
|
88
|
+
): void {
|
|
89
|
+
ctx.beginPath();
|
|
90
|
+
ctx.moveTo(seg.start.x, seg.start.y);
|
|
91
|
+
ctx.bezierCurveTo(
|
|
92
|
+
seg.controlStart.x,
|
|
93
|
+
seg.controlStart.y,
|
|
94
|
+
seg.controlEnd.x,
|
|
95
|
+
seg.controlEnd.y,
|
|
96
|
+
seg.end.x,
|
|
97
|
+
seg.end.y,
|
|
98
|
+
);
|
|
99
|
+
ctx.strokeStyle = seg.color;
|
|
100
|
+
ctx.lineWidth = SEGMENT_STROKE_WIDTH;
|
|
101
|
+
ctx.lineCap = "round";
|
|
102
|
+
ctx.stroke();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function drawNode(
|
|
106
|
+
ctx: CanvasRenderingContext2D,
|
|
107
|
+
node: MindMapNode,
|
|
108
|
+
): void {
|
|
109
|
+
const w = getNodeWrapper(node);
|
|
110
|
+
const x = w.left;
|
|
111
|
+
const y = w.top;
|
|
112
|
+
const innerX = x + node.style.wrapperPadding;
|
|
113
|
+
const innerY = y + node.style.wrapperPadding;
|
|
114
|
+
const innerW = node.style.w;
|
|
115
|
+
const innerH = node.style.h;
|
|
116
|
+
const radius = node.type === "central" ? Math.min(innerW, innerH) / 2 : 12;
|
|
117
|
+
|
|
118
|
+
if (node.type === "central") {
|
|
119
|
+
const cx = innerX + innerW / 2;
|
|
120
|
+
const cy = innerY + innerH / 2;
|
|
121
|
+
const r = Math.min(innerW, innerH) / 2;
|
|
122
|
+
ctx.fillStyle = "transparent";
|
|
123
|
+
ctx.strokeStyle = node.style.color || "#94a3b8";
|
|
124
|
+
ctx.lineWidth = 1;
|
|
125
|
+
ctx.beginPath();
|
|
126
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
127
|
+
ctx.stroke();
|
|
128
|
+
} else {
|
|
129
|
+
ctx.fillStyle = node.style.backgroundColor || "transparent";
|
|
130
|
+
ctx.strokeStyle = node.style.color || "#94a3b8";
|
|
131
|
+
ctx.lineWidth = 1;
|
|
132
|
+
roundRect(ctx, innerX, innerY, innerW, innerH, radius);
|
|
133
|
+
ctx.fill();
|
|
134
|
+
ctx.stroke();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const fontWeight = getNodeFontWeight(node);
|
|
138
|
+
const fontSize = node.style.fontSize;
|
|
139
|
+
const isItalic = node.style.isItalic;
|
|
140
|
+
const textColor = node.style.textColor || "#0f172a";
|
|
141
|
+
const align = node.style.textAlign;
|
|
142
|
+
const paddingX = node.style.padding.x;
|
|
143
|
+
const paddingY = node.style.padding.y;
|
|
144
|
+
const textAreaW = innerW - paddingX * 2;
|
|
145
|
+
const textAreaH = innerH - paddingY * 2;
|
|
146
|
+
|
|
147
|
+
if (node.type === "image") {
|
|
148
|
+
const label = node.text.trim()
|
|
149
|
+
? (node.text.length > 40 ? node.text.slice(0, 37) + "…" : node.text)
|
|
150
|
+
: "[imagem]";
|
|
151
|
+
ctx.fillStyle = "#64748b";
|
|
152
|
+
ctx.font = `${fontSize}px ${DEFAULT_FONT_FAMILY}`;
|
|
153
|
+
ctx.textAlign = "center";
|
|
154
|
+
ctx.textBaseline = "middle";
|
|
155
|
+
ctx.fillText(
|
|
156
|
+
label,
|
|
157
|
+
innerX + innerW / 2,
|
|
158
|
+
innerY + innerH / 2,
|
|
159
|
+
);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const lines = getTextLines(
|
|
164
|
+
ctx,
|
|
165
|
+
node.text,
|
|
166
|
+
textAreaW,
|
|
167
|
+
fontSize,
|
|
168
|
+
fontWeight,
|
|
169
|
+
isItalic,
|
|
170
|
+
);
|
|
171
|
+
const lineHeight = Math.ceil(fontSize * LINE_HEIGHT_FACTOR);
|
|
172
|
+
ctx.font = `${isItalic ? "italic" : "normal"} ${fontWeight} ${fontSize}px ${DEFAULT_FONT_FAMILY}`;
|
|
173
|
+
ctx.fillStyle = textColor;
|
|
174
|
+
ctx.textBaseline = "top";
|
|
175
|
+
|
|
176
|
+
const totalTextHeight = lines.length * lineHeight;
|
|
177
|
+
let startY = innerY + paddingY;
|
|
178
|
+
if (lines.length > 1 && totalTextHeight < textAreaH) {
|
|
179
|
+
const blockOffset = (textAreaH - totalTextHeight) / 2;
|
|
180
|
+
if (align === "center") startY += blockOffset;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < lines.length; i++) {
|
|
184
|
+
const lineY = startY + i * lineHeight;
|
|
185
|
+
let textX: number;
|
|
186
|
+
if (align === "center") {
|
|
187
|
+
textX = innerX + innerW / 2;
|
|
188
|
+
ctx.textAlign = "center";
|
|
189
|
+
} else if (align === "right") {
|
|
190
|
+
textX = innerX + innerW - paddingX;
|
|
191
|
+
ctx.textAlign = "right";
|
|
192
|
+
} else {
|
|
193
|
+
textX = innerX + paddingX;
|
|
194
|
+
ctx.textAlign = "left";
|
|
195
|
+
}
|
|
196
|
+
ctx.fillText(lines[i], textX, lineY, textAreaW);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function roundRect(
|
|
201
|
+
ctx: CanvasRenderingContext2D,
|
|
202
|
+
x: number,
|
|
203
|
+
y: number,
|
|
204
|
+
w: number,
|
|
205
|
+
h: number,
|
|
206
|
+
r: number,
|
|
207
|
+
): void {
|
|
208
|
+
if (r <= 0) {
|
|
209
|
+
ctx.rect(x, y, w, h);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
ctx.beginPath();
|
|
213
|
+
ctx.moveTo(x + r, y);
|
|
214
|
+
ctx.lineTo(x + w - r, y);
|
|
215
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
216
|
+
ctx.lineTo(x + w, y + h - r);
|
|
217
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
218
|
+
ctx.lineTo(x + r, y + h);
|
|
219
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
220
|
+
ctx.lineTo(x, y + r);
|
|
221
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
222
|
+
ctx.closePath();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export type ExportImageOptions = {
|
|
226
|
+
/** Scale factor (default: HIGH_QUALITY_EXPORT_SCALE). Higher = larger file, sharper when zooming. */
|
|
227
|
+
scale?: number;
|
|
228
|
+
/** Optional filename for download (without extension). */
|
|
229
|
+
filename?: string;
|
|
230
|
+
/** Background color of the exported image (e.g. "white", "#1e293b"). Defaults to "#f8fafc". */
|
|
231
|
+
exportBackgroundColor?: string;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
export type CreateMindMapCanvasOptions = {
|
|
235
|
+
scale?: number;
|
|
236
|
+
/** Background color of the exported canvas. Defaults to "#f8fafc". */
|
|
237
|
+
exportBackgroundColor?: string;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Renders the mind map to a canvas and returns it (for PNG export or PDF). Returns null if no visible nodes or canvas unavailable.
|
|
242
|
+
*/
|
|
243
|
+
export function createMindMapExportCanvas(
|
|
244
|
+
nodes: MindMapNode[],
|
|
245
|
+
options: CreateMindMapCanvasOptions = {},
|
|
246
|
+
): HTMLCanvasElement | null {
|
|
247
|
+
const scale = Math.min(4, Math.max(1, options.scale ?? HIGH_QUALITY_EXPORT_SCALE));
|
|
248
|
+
const flat = getFlatVisibleNodes(nodes);
|
|
249
|
+
if (flat.length === 0) return null;
|
|
250
|
+
|
|
251
|
+
const bounds = getNodesBounds(nodes);
|
|
252
|
+
if (!bounds) return null;
|
|
253
|
+
|
|
254
|
+
const padding = EXPORT_PADDING;
|
|
255
|
+
const contentW = bounds.maxX - bounds.minX + padding * 2;
|
|
256
|
+
const contentH = bounds.maxY - bounds.minY + padding * 2;
|
|
257
|
+
|
|
258
|
+
const maxScaleByW = contentW > 0 ? MAX_CANVAS_DIMENSION / contentW : scale;
|
|
259
|
+
const maxScaleByH = contentH > 0 ? MAX_CANVAS_DIMENSION / contentH : scale;
|
|
260
|
+
const effectiveScale = Math.min(scale, maxScaleByW, maxScaleByH);
|
|
261
|
+
const canvasW = Math.min(
|
|
262
|
+
MAX_CANVAS_DIMENSION,
|
|
263
|
+
Math.ceil(contentW * effectiveScale),
|
|
264
|
+
);
|
|
265
|
+
const canvasH = Math.min(
|
|
266
|
+
MAX_CANVAS_DIMENSION,
|
|
267
|
+
Math.ceil(contentH * effectiveScale),
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const canvas = document.createElement("canvas");
|
|
271
|
+
canvas.width = canvasW;
|
|
272
|
+
canvas.height = canvasH;
|
|
273
|
+
const ctx = canvas.getContext("2d");
|
|
274
|
+
if (!ctx) return null;
|
|
275
|
+
|
|
276
|
+
const backgroundColor = options.exportBackgroundColor ?? "#f8fafc";
|
|
277
|
+
ctx.fillStyle = backgroundColor;
|
|
278
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
279
|
+
|
|
280
|
+
ctx.save();
|
|
281
|
+
ctx.scale(effectiveScale, effectiveScale);
|
|
282
|
+
ctx.translate(-bounds.minX + padding, -bounds.minY + padding);
|
|
283
|
+
|
|
284
|
+
const segmentLines = getSegmentLines(nodes);
|
|
285
|
+
for (const seg of segmentLines) drawSegment(ctx, seg);
|
|
286
|
+
for (const node of flat) drawNode(ctx, node);
|
|
287
|
+
|
|
288
|
+
ctx.restore();
|
|
289
|
+
return canvas;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Renders the current mind map to a high-resolution PNG and triggers a download.
|
|
294
|
+
* Uses a scale factor so that zooming into the image keeps text readable even on very large maps.
|
|
295
|
+
* Must be called in a browser environment with the mind map already mounted.
|
|
296
|
+
*/
|
|
297
|
+
export function exportMindMapAsHighQualityImage(
|
|
298
|
+
nodes: MindMapNode[],
|
|
299
|
+
options: ExportImageOptions = {},
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
const filename = options.filename ?? `mind-map-${Date.now()}`;
|
|
302
|
+
const canvas = createMindMapExportCanvas(nodes, {
|
|
303
|
+
scale: options.scale,
|
|
304
|
+
exportBackgroundColor: options.exportBackgroundColor,
|
|
305
|
+
});
|
|
306
|
+
if (!canvas) return Promise.resolve();
|
|
307
|
+
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
canvas.toBlob(
|
|
310
|
+
(blob) => {
|
|
311
|
+
if (!blob) {
|
|
312
|
+
reject(new Error("Failed to create image blob"));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const url = URL.createObjectURL(blob);
|
|
316
|
+
const a = document.createElement("a");
|
|
317
|
+
a.href = url;
|
|
318
|
+
a.download = `${filename}.png`;
|
|
319
|
+
a.click();
|
|
320
|
+
URL.revokeObjectURL(url);
|
|
321
|
+
resolve();
|
|
322
|
+
},
|
|
323
|
+
"image/png",
|
|
324
|
+
1,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { MindMapNode } from "../state/mindMap";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escapes markdown special characters in a line so it can be used inside list items.
|
|
5
|
+
*/
|
|
6
|
+
function escapeMarkdownLine(text: string): string {
|
|
7
|
+
return text
|
|
8
|
+
.replace(/\\/g, "\\\\")
|
|
9
|
+
.replace(/^#+\s/g, (m) => "\\".repeat(m.length) + m)
|
|
10
|
+
.replace(/^\s*[-*+]\s/g, "\\- ")
|
|
11
|
+
.replace(/^\d+\.\s/g, (m) => m.replace(".", "\\."))
|
|
12
|
+
.replace(/\[/g, "\\[")
|
|
13
|
+
.replace(/\]/g, "\\]")
|
|
14
|
+
.replace(/^>/g, "\\>")
|
|
15
|
+
.replace(/^\s*\d+\./gm, (m) => m.replace(".", "\\."));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function nodeToMarkdownLine(node: MindMapNode): string {
|
|
19
|
+
const trimmed = node.text.trim();
|
|
20
|
+
if (node.type === "image") {
|
|
21
|
+
if (!trimmed) return "[imagem]";
|
|
22
|
+
try {
|
|
23
|
+
new URL(trimmed);
|
|
24
|
+
return `[imagem](${trimmed})`;
|
|
25
|
+
} catch {
|
|
26
|
+
return trimmed || "[imagem]";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return escapeMarkdownLine(trimmed || "(vazio)");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const MAX_HEADING_LEVEL = 6;
|
|
33
|
+
|
|
34
|
+
function walkToMarkdown(nodes: MindMapNode[], depth: number): string[] {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
const visible = nodes.filter((n) => n.isVisible);
|
|
37
|
+
const sorted = [...visible].sort((a, b) => a.sequence - b.sequence);
|
|
38
|
+
const headingLevel = Math.min(depth + 1, MAX_HEADING_LEVEL);
|
|
39
|
+
const prefix = "#".repeat(headingLevel);
|
|
40
|
+
|
|
41
|
+
for (const node of sorted) {
|
|
42
|
+
const content = nodeToMarkdownLine(node);
|
|
43
|
+
lines.push(`${prefix} ${content}`);
|
|
44
|
+
lines.push("");
|
|
45
|
+
if (node.childrens.length > 0) {
|
|
46
|
+
lines.push(...walkToMarkdown(node.childrens, depth + 1));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Converts the mind map tree to formatted Markdown.
|
|
54
|
+
* Hierarchy is expressed with heading levels: # (central), ## (first level), ### (second), etc.
|
|
55
|
+
* So the outline is clear in any Markdown viewer.
|
|
56
|
+
*/
|
|
57
|
+
export function mindMapToMarkdown(nodes: MindMapNode[]): string {
|
|
58
|
+
if (nodes.length === 0) return "";
|
|
59
|
+
|
|
60
|
+
const roots = nodes.filter((n) => n.isVisible);
|
|
61
|
+
if (roots.length === 0) return "";
|
|
62
|
+
|
|
63
|
+
const lines: string[] = [];
|
|
64
|
+
const sortedRoots = [...roots].sort((a, b) => a.sequence - b.sequence);
|
|
65
|
+
|
|
66
|
+
for (const root of sortedRoots) {
|
|
67
|
+
const title = root.text.trim() || "Mapa mental";
|
|
68
|
+
const escapedTitle = title.replace(/^#+\s*/, "").replace(/\n/g, " ");
|
|
69
|
+
lines.push(`# ${escapedTitle}`);
|
|
70
|
+
lines.push("");
|
|
71
|
+
if (root.childrens.length > 0) {
|
|
72
|
+
lines.push(...walkToMarkdown(root.childrens, 1));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return lines.join("\n").trimEnd();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ExportMarkdownOptions = {
|
|
80
|
+
/** Filename for download (without extension). */
|
|
81
|
+
filename?: string;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Builds markdown from the given nodes and triggers download of a .md file.
|
|
86
|
+
*/
|
|
87
|
+
export function exportMindMapAsMarkdown(
|
|
88
|
+
nodes: MindMapNode[],
|
|
89
|
+
options: ExportMarkdownOptions = {},
|
|
90
|
+
): void {
|
|
91
|
+
const md = mindMapToMarkdown(nodes);
|
|
92
|
+
if (!md) return;
|
|
93
|
+
|
|
94
|
+
const filename = options.filename ?? `mind-map-${Date.now()}`;
|
|
95
|
+
const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
|
|
96
|
+
const url = URL.createObjectURL(blob);
|
|
97
|
+
const a = document.createElement("a");
|
|
98
|
+
a.href = url;
|
|
99
|
+
a.download = `${filename}.md`;
|
|
100
|
+
a.click();
|
|
101
|
+
URL.revokeObjectURL(url);
|
|
102
|
+
}
|