beads-map 0.3.4 → 0.3.6
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/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +2 -2
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/next-minimal-server.js.nft.json +1 -1
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/beads.body +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +2 -2
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +1 -1
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/page-40b0a256ec8af9de.js +1 -0
- package/.next/static/css/454d96c40653b263.css +3 -0
- package/app/page.tsx +23 -0
- package/components/DescriptionModal.tsx +507 -11
- package/components/HelpPanel.tsx +4 -1
- package/components/NodeDetail.tsx +3 -1
- package/components/SettingsModal.tsx +237 -0
- package/components/TutorialOverlay.tsx +3 -3
- package/lib/settings.ts +42 -0
- package/lib/tts.ts +397 -0
- package/package.json +1 -1
- package/.next/static/chunks/app/page-cf8e14cb4afc8112.js +0 -1
- package/.next/static/css/ade5301262971664.css +0 -3
- /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_buildManifest.js +0 -0
- /package/.next/static/{JmL0suxsggbSwPxWcmUFV → msGLXabX0J07WQq7roVpb}/_ssgManifest.js +0 -0
|
@@ -1,29 +1,211 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { useState, useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
5
|
import ReactMarkdown from "react-markdown";
|
|
6
6
|
import remarkGfm from "remark-gfm";
|
|
7
7
|
import type { GraphNode } from "@/lib/types";
|
|
8
8
|
import { buildDescriptionCopyText } from "@/lib/utils";
|
|
9
|
+
import {
|
|
10
|
+
speakWithElevenLabs,
|
|
11
|
+
speakSelection,
|
|
12
|
+
stopTts,
|
|
13
|
+
pauseTts,
|
|
14
|
+
resumeTts,
|
|
15
|
+
stripMarkdown,
|
|
16
|
+
setTtsPlaybackRate,
|
|
17
|
+
type TtsState,
|
|
18
|
+
} from "@/lib/tts";
|
|
19
|
+
import { hasApiKey } from "@/lib/settings";
|
|
20
|
+
|
|
21
|
+
const SPEED_PRESETS = [
|
|
22
|
+
{ label: "Normal", value: 1 },
|
|
23
|
+
{ label: "1.25x", value: 1.25 },
|
|
24
|
+
{ label: "1.5x", value: 1.5 },
|
|
25
|
+
{ label: "1.75x", value: 1.75 },
|
|
26
|
+
{ label: "2x", value: 2 },
|
|
27
|
+
];
|
|
9
28
|
|
|
10
29
|
interface DescriptionModalProps {
|
|
11
30
|
node: GraphNode;
|
|
12
31
|
onClose: () => void;
|
|
13
32
|
repoUrl?: string;
|
|
33
|
+
onOpenSettings?: () => void;
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
export function DescriptionModal({
|
|
36
|
+
export function DescriptionModal({
|
|
37
|
+
node,
|
|
38
|
+
onClose,
|
|
39
|
+
repoUrl,
|
|
40
|
+
onOpenSettings,
|
|
41
|
+
}: DescriptionModalProps) {
|
|
17
42
|
const [copied, setCopied] = useState(false);
|
|
43
|
+
const [ttsState, setTtsState] = useState<TtsState>("idle");
|
|
44
|
+
const [ttsError, setTtsError] = useState<string | null>(null);
|
|
45
|
+
const [ttsSpeed, setTtsSpeedState] = useState(1);
|
|
46
|
+
const [speedMenuOpen, setSpeedMenuOpen] = useState(false);
|
|
47
|
+
const [customSpeedInput, setCustomSpeedInput] = useState("");
|
|
48
|
+
const [showCustomInput, setShowCustomInput] = useState(false);
|
|
49
|
+
const speedBtnRef = useRef<HTMLButtonElement>(null);
|
|
50
|
+
const speedMenuRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
// Selection tooltip state
|
|
53
|
+
const [selectionTooltip, setSelectionTooltip] = useState<{
|
|
54
|
+
text: string;
|
|
55
|
+
x: number;
|
|
56
|
+
y: number;
|
|
57
|
+
} | null>(null);
|
|
58
|
+
const selectionTooltipRef = useRef<HTMLDivElement>(null);
|
|
59
|
+
// Guard: when true, selectionchange listener won't clear the tooltip
|
|
60
|
+
// (prevents race where clicking the tooltip clears browser selection before handler runs)
|
|
61
|
+
const ttsStartingRef = useRef(false);
|
|
18
62
|
|
|
19
63
|
const handleCopy = () => {
|
|
20
64
|
if (!node.description) return;
|
|
21
|
-
navigator.clipboard
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
65
|
+
navigator.clipboard
|
|
66
|
+
.writeText(buildDescriptionCopyText(node, repoUrl))
|
|
67
|
+
.then(() => {
|
|
68
|
+
setCopied(true);
|
|
69
|
+
setTimeout(() => setCopied(false), 1500);
|
|
70
|
+
});
|
|
25
71
|
};
|
|
26
72
|
|
|
73
|
+
// --- TTS handlers -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const ttsStateChange = useCallback((state: TtsState, error?: string) => {
|
|
76
|
+
setTtsState(state);
|
|
77
|
+
if (error) setTtsError(error);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
const handleTts = useCallback(() => {
|
|
81
|
+
if (!hasApiKey()) {
|
|
82
|
+
onOpenSettings?.();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const plainText = stripMarkdown(node.description || "");
|
|
86
|
+
if (!plainText) return;
|
|
87
|
+
setTtsError(null);
|
|
88
|
+
speakWithElevenLabs(plainText, ttsStateChange);
|
|
89
|
+
}, [node.description, onOpenSettings, ttsStateChange]);
|
|
90
|
+
|
|
91
|
+
const handleStopTts = useCallback(() => {
|
|
92
|
+
stopTts();
|
|
93
|
+
setTtsState("idle");
|
|
94
|
+
setSpeedMenuOpen(false);
|
|
95
|
+
setShowCustomInput(false);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
const handlePauseTts = useCallback(() => {
|
|
99
|
+
pauseTts();
|
|
100
|
+
setTtsState("paused");
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const handleResumeTts = useCallback(() => {
|
|
104
|
+
resumeTts();
|
|
105
|
+
setTtsState("playing");
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
const handleSpeedChange = useCallback((speed: number) => {
|
|
109
|
+
const clamped = Math.max(0.25, Math.min(4, speed));
|
|
110
|
+
setTtsSpeedState(clamped);
|
|
111
|
+
setTtsPlaybackRate(clamped);
|
|
112
|
+
setSpeedMenuOpen(false);
|
|
113
|
+
setShowCustomInput(false);
|
|
114
|
+
}, []);
|
|
115
|
+
|
|
116
|
+
// Apply speed when playback starts (carries over from previous session)
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (ttsState === "playing") {
|
|
119
|
+
setTtsPlaybackRate(ttsSpeed);
|
|
120
|
+
}
|
|
121
|
+
}, [ttsState, ttsSpeed]);
|
|
122
|
+
|
|
123
|
+
// Click-outside handler for speed menu
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
if (!speedMenuOpen) return;
|
|
126
|
+
const handler = (e: MouseEvent) => {
|
|
127
|
+
if (
|
|
128
|
+
speedMenuRef.current && !speedMenuRef.current.contains(e.target as Node) &&
|
|
129
|
+
speedBtnRef.current && !speedBtnRef.current.contains(e.target as Node)
|
|
130
|
+
) {
|
|
131
|
+
setSpeedMenuOpen(false);
|
|
132
|
+
setShowCustomInput(false);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const timer = setTimeout(() => window.addEventListener("mousedown", handler), 50);
|
|
136
|
+
return () => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
window.removeEventListener("mousedown", handler);
|
|
139
|
+
};
|
|
140
|
+
}, [speedMenuOpen]);
|
|
141
|
+
|
|
142
|
+
// --- Selection tooltip handlers -----------------------------------------
|
|
143
|
+
|
|
144
|
+
const handleSelectionMouseUp = useCallback(() => {
|
|
145
|
+
// Small delay to let the browser finalize the selection
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
const sel = window.getSelection();
|
|
148
|
+
if (!sel || sel.isCollapsed) return;
|
|
149
|
+
const selectedText = sel.toString().trim();
|
|
150
|
+
if (selectedText.length < 3) return;
|
|
151
|
+
try {
|
|
152
|
+
const range = sel.getRangeAt(0);
|
|
153
|
+
const rect = range.getBoundingClientRect();
|
|
154
|
+
setSelectionTooltip({
|
|
155
|
+
text: selectedText,
|
|
156
|
+
x: rect.left + rect.width / 2,
|
|
157
|
+
y: rect.top - 8,
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// getRangeAt can throw if selection is weird
|
|
161
|
+
}
|
|
162
|
+
}, 10);
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
// Clear tooltip when selection is lost (guarded during TTS initiation)
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
const handler = () => {
|
|
168
|
+
if (ttsStartingRef.current) return;
|
|
169
|
+
const sel = window.getSelection();
|
|
170
|
+
if (!sel || sel.isCollapsed || !sel.toString().trim()) {
|
|
171
|
+
setSelectionTooltip(null);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
document.addEventListener("selectionchange", handler);
|
|
175
|
+
return () => document.removeEventListener("selectionchange", handler);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
const handleSelectionTts = useCallback(() => {
|
|
179
|
+
if (!selectionTooltip) return;
|
|
180
|
+
const text = selectionTooltip.text;
|
|
181
|
+
if (!text.trim()) return;
|
|
182
|
+
|
|
183
|
+
if (!hasApiKey()) {
|
|
184
|
+
onOpenSettings?.();
|
|
185
|
+
setSelectionTooltip(null);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ttsStartingRef.current = true; // Guard against selectionchange race
|
|
190
|
+
setTtsError(null);
|
|
191
|
+
setSelectionTooltip(null);
|
|
192
|
+
|
|
193
|
+
// speakSelection() checks cache first — zero API call if full text was played
|
|
194
|
+
speakSelection(text, ttsStateChange);
|
|
195
|
+
|
|
196
|
+
// Release guard after a tick (React state updates are batched)
|
|
197
|
+
setTimeout(() => { ttsStartingRef.current = false; }, 100);
|
|
198
|
+
}, [selectionTooltip, onOpenSettings, ttsStateChange]);
|
|
199
|
+
|
|
200
|
+
// Stop TTS on unmount / modal close
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
return () => {
|
|
203
|
+
stopTts();
|
|
204
|
+
setSpeedMenuOpen(false);
|
|
205
|
+
setSelectionTooltip(null);
|
|
206
|
+
};
|
|
207
|
+
}, []);
|
|
208
|
+
|
|
27
209
|
if (!node.description) return null;
|
|
28
210
|
|
|
29
211
|
return createPortal(
|
|
@@ -46,21 +228,270 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
|
|
|
46
228
|
</span>
|
|
47
229
|
</div>
|
|
48
230
|
<div className="flex items-center gap-1 shrink-0">
|
|
231
|
+
{/* Copy button */}
|
|
49
232
|
<button
|
|
50
233
|
onClick={handleCopy}
|
|
51
234
|
className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
|
|
52
235
|
title="Copy description"
|
|
53
236
|
>
|
|
54
237
|
{copied ? (
|
|
55
|
-
<svg
|
|
56
|
-
|
|
238
|
+
<svg
|
|
239
|
+
className="w-4 h-4 text-emerald-500"
|
|
240
|
+
fill="none"
|
|
241
|
+
stroke="currentColor"
|
|
242
|
+
viewBox="0 0 24 24"
|
|
243
|
+
strokeWidth={2}
|
|
244
|
+
>
|
|
245
|
+
<path
|
|
246
|
+
strokeLinecap="round"
|
|
247
|
+
strokeLinejoin="round"
|
|
248
|
+
d="M4.5 12.75l6 6 9-13.5"
|
|
249
|
+
/>
|
|
57
250
|
</svg>
|
|
58
251
|
) : (
|
|
59
|
-
<svg
|
|
60
|
-
|
|
252
|
+
<svg
|
|
253
|
+
className="w-4 h-4"
|
|
254
|
+
fill="none"
|
|
255
|
+
stroke="currentColor"
|
|
256
|
+
viewBox="0 0 24 24"
|
|
257
|
+
strokeWidth={2}
|
|
258
|
+
>
|
|
259
|
+
<path
|
|
260
|
+
strokeLinecap="round"
|
|
261
|
+
strokeLinejoin="round"
|
|
262
|
+
d="M15.666 3.888A2.25 2.25 0 0 0 13.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 0 1-.75.75H9.75a.75.75 0 0 1-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 0 1 1.927-.184"
|
|
263
|
+
/>
|
|
61
264
|
</svg>
|
|
62
265
|
)}
|
|
63
266
|
</button>
|
|
267
|
+
|
|
268
|
+
{/* TTS buttons — 4 states: idle, loading, playing, paused */}
|
|
269
|
+
{ttsState === "loading" ? (
|
|
270
|
+
<button
|
|
271
|
+
disabled
|
|
272
|
+
className="p-1 text-zinc-300 cursor-wait"
|
|
273
|
+
title="Loading audio..."
|
|
274
|
+
>
|
|
275
|
+
<svg
|
|
276
|
+
className="w-4 h-4 animate-spin"
|
|
277
|
+
fill="none"
|
|
278
|
+
viewBox="0 0 24 24"
|
|
279
|
+
>
|
|
280
|
+
<circle
|
|
281
|
+
className="opacity-25"
|
|
282
|
+
cx="12"
|
|
283
|
+
cy="12"
|
|
284
|
+
r="10"
|
|
285
|
+
stroke="currentColor"
|
|
286
|
+
strokeWidth={4}
|
|
287
|
+
/>
|
|
288
|
+
<path
|
|
289
|
+
className="opacity-75"
|
|
290
|
+
fill="currentColor"
|
|
291
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
292
|
+
/>
|
|
293
|
+
</svg>
|
|
294
|
+
</button>
|
|
295
|
+
) : ttsState === "playing" ? (
|
|
296
|
+
<>
|
|
297
|
+
{/* Pause button */}
|
|
298
|
+
<button
|
|
299
|
+
onClick={handlePauseTts}
|
|
300
|
+
className="p-1 text-emerald-500 hover:text-amber-500 hover:bg-amber-50 rounded transition-colors"
|
|
301
|
+
title="Pause"
|
|
302
|
+
>
|
|
303
|
+
<svg
|
|
304
|
+
className="w-4 h-4"
|
|
305
|
+
fill="none"
|
|
306
|
+
viewBox="0 0 24 24"
|
|
307
|
+
strokeWidth={1.5}
|
|
308
|
+
stroke="currentColor"
|
|
309
|
+
>
|
|
310
|
+
<path
|
|
311
|
+
strokeLinecap="round"
|
|
312
|
+
strokeLinejoin="round"
|
|
313
|
+
d="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
|
314
|
+
/>
|
|
315
|
+
</svg>
|
|
316
|
+
</button>
|
|
317
|
+
{/* Stop button */}
|
|
318
|
+
<button
|
|
319
|
+
onClick={handleStopTts}
|
|
320
|
+
className="p-1 text-zinc-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
|
321
|
+
title="Stop"
|
|
322
|
+
>
|
|
323
|
+
<svg
|
|
324
|
+
className="w-4 h-4"
|
|
325
|
+
fill="none"
|
|
326
|
+
viewBox="0 0 24 24"
|
|
327
|
+
strokeWidth={1.5}
|
|
328
|
+
stroke="currentColor"
|
|
329
|
+
>
|
|
330
|
+
<path
|
|
331
|
+
strokeLinecap="round"
|
|
332
|
+
strokeLinejoin="round"
|
|
333
|
+
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
|
334
|
+
/>
|
|
335
|
+
</svg>
|
|
336
|
+
</button>
|
|
337
|
+
</>
|
|
338
|
+
) : ttsState === "paused" ? (
|
|
339
|
+
<>
|
|
340
|
+
{/* Resume button */}
|
|
341
|
+
<button
|
|
342
|
+
onClick={handleResumeTts}
|
|
343
|
+
className="p-1 text-amber-500 hover:text-emerald-500 hover:bg-emerald-50 rounded transition-colors"
|
|
344
|
+
title="Resume"
|
|
345
|
+
>
|
|
346
|
+
<svg
|
|
347
|
+
className="w-4 h-4"
|
|
348
|
+
fill="none"
|
|
349
|
+
viewBox="0 0 24 24"
|
|
350
|
+
strokeWidth={1.5}
|
|
351
|
+
stroke="currentColor"
|
|
352
|
+
>
|
|
353
|
+
<path
|
|
354
|
+
strokeLinecap="round"
|
|
355
|
+
strokeLinejoin="round"
|
|
356
|
+
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
|
357
|
+
/>
|
|
358
|
+
</svg>
|
|
359
|
+
</button>
|
|
360
|
+
{/* Stop button */}
|
|
361
|
+
<button
|
|
362
|
+
onClick={handleStopTts}
|
|
363
|
+
className="p-1 text-zinc-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
|
364
|
+
title="Stop"
|
|
365
|
+
>
|
|
366
|
+
<svg
|
|
367
|
+
className="w-4 h-4"
|
|
368
|
+
fill="none"
|
|
369
|
+
viewBox="0 0 24 24"
|
|
370
|
+
strokeWidth={1.5}
|
|
371
|
+
stroke="currentColor"
|
|
372
|
+
>
|
|
373
|
+
<path
|
|
374
|
+
strokeLinecap="round"
|
|
375
|
+
strokeLinejoin="round"
|
|
376
|
+
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
|
377
|
+
/>
|
|
378
|
+
</svg>
|
|
379
|
+
</button>
|
|
380
|
+
</>
|
|
381
|
+
) : (
|
|
382
|
+
/* Idle — play/speaker button */
|
|
383
|
+
<button
|
|
384
|
+
onClick={handleTts}
|
|
385
|
+
className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
|
|
386
|
+
title="Read aloud"
|
|
387
|
+
>
|
|
388
|
+
<svg
|
|
389
|
+
className="w-4 h-4"
|
|
390
|
+
fill="none"
|
|
391
|
+
viewBox="0 0 24 24"
|
|
392
|
+
strokeWidth={1.5}
|
|
393
|
+
stroke="currentColor"
|
|
394
|
+
>
|
|
395
|
+
<path
|
|
396
|
+
strokeLinecap="round"
|
|
397
|
+
strokeLinejoin="round"
|
|
398
|
+
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
|
399
|
+
/>
|
|
400
|
+
</svg>
|
|
401
|
+
</button>
|
|
402
|
+
)}
|
|
403
|
+
|
|
404
|
+
{/* Speed selector — visible during playback/loading/paused */}
|
|
405
|
+
{(ttsState === "playing" || ttsState === "loading" || ttsState === "paused") && (
|
|
406
|
+
<div className="relative">
|
|
407
|
+
<button
|
|
408
|
+
ref={speedBtnRef}
|
|
409
|
+
onClick={() => setSpeedMenuOpen((v) => !v)}
|
|
410
|
+
className="px-1.5 py-0.5 text-[11px] font-mono font-medium rounded text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 transition-colors"
|
|
411
|
+
title="Playback speed"
|
|
412
|
+
>
|
|
413
|
+
{ttsSpeed === 1 ? "1x" : `${ttsSpeed}x`}
|
|
414
|
+
</button>
|
|
415
|
+
|
|
416
|
+
{speedMenuOpen && (
|
|
417
|
+
<div
|
|
418
|
+
ref={speedMenuRef}
|
|
419
|
+
className="absolute right-0 top-full mt-1 bg-white border border-zinc-200 rounded-lg shadow-lg overflow-hidden z-[110]"
|
|
420
|
+
style={{ minWidth: 150 }}
|
|
421
|
+
onClick={(e) => e.stopPropagation()}
|
|
422
|
+
>
|
|
423
|
+
{SPEED_PRESETS.map((preset) => (
|
|
424
|
+
<button
|
|
425
|
+
key={preset.value}
|
|
426
|
+
onClick={() => handleSpeedChange(preset.value)}
|
|
427
|
+
className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
|
|
428
|
+
ttsSpeed === preset.value ? "text-emerald-600 font-medium" : "text-zinc-700"
|
|
429
|
+
}`}
|
|
430
|
+
>
|
|
431
|
+
<span>{preset.label}</span>
|
|
432
|
+
{ttsSpeed === preset.value && (
|
|
433
|
+
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
434
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
435
|
+
</svg>
|
|
436
|
+
)}
|
|
437
|
+
</button>
|
|
438
|
+
))}
|
|
439
|
+
|
|
440
|
+
<div className="border-t border-zinc-100" />
|
|
441
|
+
|
|
442
|
+
{!showCustomInput ? (
|
|
443
|
+
<button
|
|
444
|
+
onClick={() => {
|
|
445
|
+
setShowCustomInput(true);
|
|
446
|
+
setCustomSpeedInput(String(ttsSpeed));
|
|
447
|
+
}}
|
|
448
|
+
className={`w-full px-3 py-2 text-xs text-left flex items-center justify-between hover:bg-zinc-50 transition-colors ${
|
|
449
|
+
!SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? "text-emerald-600 font-medium" : "text-zinc-700"
|
|
450
|
+
}`}
|
|
451
|
+
>
|
|
452
|
+
<span>Custom{!SPEED_PRESETS.some((p) => p.value === ttsSpeed) ? ` (${ttsSpeed}x)` : "..."}</span>
|
|
453
|
+
{!SPEED_PRESETS.some((p) => p.value === ttsSpeed) && (
|
|
454
|
+
<svg className="w-3.5 h-3.5 text-emerald-500" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
|
455
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
|
456
|
+
</svg>
|
|
457
|
+
)}
|
|
458
|
+
</button>
|
|
459
|
+
) : (
|
|
460
|
+
<div className="px-3 py-2 flex items-center gap-2">
|
|
461
|
+
<input
|
|
462
|
+
type="number"
|
|
463
|
+
min={0.25}
|
|
464
|
+
max={4}
|
|
465
|
+
step={0.25}
|
|
466
|
+
value={customSpeedInput}
|
|
467
|
+
onChange={(e) => setCustomSpeedInput(e.target.value)}
|
|
468
|
+
onKeyDown={(e) => {
|
|
469
|
+
if (e.key === "Enter") {
|
|
470
|
+
const val = parseFloat(customSpeedInput);
|
|
471
|
+
if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
|
|
472
|
+
}
|
|
473
|
+
if (e.key === "Escape") {
|
|
474
|
+
setShowCustomInput(false);
|
|
475
|
+
setSpeedMenuOpen(false);
|
|
476
|
+
}
|
|
477
|
+
}}
|
|
478
|
+
onBlur={() => {
|
|
479
|
+
const val = parseFloat(customSpeedInput);
|
|
480
|
+
if (!isNaN(val) && val >= 0.25 && val <= 4) handleSpeedChange(val);
|
|
481
|
+
else setShowCustomInput(false);
|
|
482
|
+
}}
|
|
483
|
+
autoFocus
|
|
484
|
+
className="w-16 rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-900 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
|
485
|
+
/>
|
|
486
|
+
<span className="text-[11px] text-zinc-400">x</span>
|
|
487
|
+
</div>
|
|
488
|
+
)}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
493
|
+
|
|
494
|
+
{/* Close button */}
|
|
64
495
|
<button
|
|
65
496
|
onClick={onClose}
|
|
66
497
|
className="p-1 text-zinc-400 hover:text-zinc-600 transition-colors"
|
|
@@ -81,13 +512,78 @@ export function DescriptionModal({ node, onClose, repoUrl }: DescriptionModalPro
|
|
|
81
512
|
</button>
|
|
82
513
|
</div>
|
|
83
514
|
</div>
|
|
515
|
+
|
|
516
|
+
{/* TTS error banner */}
|
|
517
|
+
{ttsError && (
|
|
518
|
+
<div className="px-5 py-2 text-xs text-red-500 bg-red-50 border-b border-red-100 flex items-center justify-between">
|
|
519
|
+
<span>{ttsError}</span>
|
|
520
|
+
<button
|
|
521
|
+
onClick={() => setTtsError(null)}
|
|
522
|
+
className="text-red-400 hover:text-red-600 ml-2"
|
|
523
|
+
>
|
|
524
|
+
<svg
|
|
525
|
+
className="w-3 h-3"
|
|
526
|
+
fill="none"
|
|
527
|
+
viewBox="0 0 24 24"
|
|
528
|
+
strokeWidth={2}
|
|
529
|
+
stroke="currentColor"
|
|
530
|
+
>
|
|
531
|
+
<path
|
|
532
|
+
strokeLinecap="round"
|
|
533
|
+
strokeLinejoin="round"
|
|
534
|
+
d="M6 18L18 6M6 6l12 12"
|
|
535
|
+
/>
|
|
536
|
+
</svg>
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
)}
|
|
540
|
+
|
|
84
541
|
{/* Modal body */}
|
|
85
|
-
<div
|
|
542
|
+
<div
|
|
543
|
+
className="flex-1 overflow-y-auto px-5 py-4 custom-scrollbar description-markdown text-sm text-zinc-700 leading-relaxed"
|
|
544
|
+
onMouseUp={handleSelectionMouseUp}
|
|
545
|
+
>
|
|
86
546
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
87
547
|
{node.description}
|
|
88
548
|
</ReactMarkdown>
|
|
89
549
|
</div>
|
|
90
550
|
</div>
|
|
551
|
+
|
|
552
|
+
{/* Selection TTS tooltip — outside modal card, inside portal backdrop */}
|
|
553
|
+
{selectionTooltip && (
|
|
554
|
+
<div
|
|
555
|
+
ref={selectionTooltipRef}
|
|
556
|
+
className="fixed z-[110] flex items-center gap-1.5 px-2.5 py-1.5 bg-zinc-800 text-white text-xs rounded-lg shadow-lg select-none"
|
|
557
|
+
style={{
|
|
558
|
+
left: selectionTooltip.x,
|
|
559
|
+
top: selectionTooltip.y,
|
|
560
|
+
transform: "translate(-50%, -100%)",
|
|
561
|
+
pointerEvents: "auto",
|
|
562
|
+
}}
|
|
563
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
564
|
+
onClick={(e) => e.stopPropagation()}
|
|
565
|
+
>
|
|
566
|
+
<button
|
|
567
|
+
onClick={handleSelectionTts}
|
|
568
|
+
className="flex items-center gap-1 hover:text-emerald-300 transition-colors"
|
|
569
|
+
>
|
|
570
|
+
<svg
|
|
571
|
+
className="w-3.5 h-3.5"
|
|
572
|
+
fill="none"
|
|
573
|
+
viewBox="0 0 24 24"
|
|
574
|
+
strokeWidth={1.5}
|
|
575
|
+
stroke="currentColor"
|
|
576
|
+
>
|
|
577
|
+
<path
|
|
578
|
+
strokeLinecap="round"
|
|
579
|
+
strokeLinejoin="round"
|
|
580
|
+
d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z"
|
|
581
|
+
/>
|
|
582
|
+
</svg>
|
|
583
|
+
<span>Read aloud</span>
|
|
584
|
+
</button>
|
|
585
|
+
</div>
|
|
586
|
+
)}
|
|
91
587
|
</div>,
|
|
92
588
|
document.body
|
|
93
589
|
);
|
package/components/HelpPanel.tsx
CHANGED
|
@@ -268,7 +268,7 @@ function HelpContent({ onStartTutorial }: { onStartTutorial?: () => void }) {
|
|
|
268
268
|
<ul className="space-y-1.5 mb-3">
|
|
269
269
|
<Bullet color={CAT.blue}><strong>Click</strong> a node to open its details</Bullet>
|
|
270
270
|
<Bullet color={CAT.blue}><strong>Hover</strong> for a quick summary</Bullet>
|
|
271
|
-
<Bullet color={CAT.blue}><strong>Right-click</strong> for actions — descriptions, comments, claims, collapse</Bullet>
|
|
271
|
+
<Bullet color={CAT.blue}><strong>Right-click</strong> for actions — descriptions, comments, claims, collapse, or focus on an epic</Bullet>
|
|
272
272
|
<Bullet color={CAT.blue}><strong>Scroll</strong> to zoom, <strong>drag</strong> to pan</Bullet>
|
|
273
273
|
<Bullet color={CAT.blue}><strong>Cmd/Ctrl+F</strong> to search by name, ID, owner, or commenter</Bullet>
|
|
274
274
|
</ul>
|
|
@@ -300,12 +300,15 @@ function HelpContent({ onStartTutorial }: { onStartTutorial?: () => void }) {
|
|
|
300
300
|
<SectionTitle color={CAT.mauve}>More</SectionTitle>
|
|
301
301
|
<ul className="space-y-1.5 mb-3">
|
|
302
302
|
<Bullet color={CAT.mauve}><strong>Collapse/Expand</strong> — tidy up epics into single nodes</Bullet>
|
|
303
|
+
<Bullet color={CAT.mauve}><strong>Focus on epic</strong> — right-click an epic to isolate its subgraph (children + dependencies). Click the banner or right-click again to return</Bullet>
|
|
303
304
|
<Bullet color={CAT.mauve}><strong>Clusters</strong> — dashed circles grouping projects when zoomed out</Bullet>
|
|
304
305
|
<Bullet color={CAT.mauve}><strong>Replay</strong> — step through your project's history</Bullet>
|
|
305
306
|
<Bullet color={CAT.mauve}><strong>Comments</strong> — leave notes on tasks (sign in first)</Bullet>
|
|
307
|
+
<Bullet color={CAT.mauve}><strong>Activity feed</strong> — real-time feed filtered to only your local beads, not global</Bullet>
|
|
306
308
|
<Bullet color={CAT.mauve}><strong>Claim tasks</strong> — right-click to mark as yours</Bullet>
|
|
307
309
|
<Bullet color={CAT.mauve}><strong>Minimap</strong> — click to jump, drag edges to resize</Bullet>
|
|
308
310
|
<Bullet color={CAT.mauve}><strong>Auto-fit</strong> — top-left toggle to lock/unlock automatic camera reframing</Bullet>
|
|
311
|
+
<Bullet color={CAT.mauve}><strong>Pulse</strong> — the most recently active node pulses with emerald ripples. Toggle it on/off in view controls</Bullet>
|
|
309
312
|
<Bullet color={CAT.mauve}><strong>Copy</strong> — clipboard icon copies task with project info</Bullet>
|
|
310
313
|
</ul>
|
|
311
314
|
|
|
@@ -30,6 +30,7 @@ interface NodeDetailProps {
|
|
|
30
30
|
isAuthenticated?: boolean;
|
|
31
31
|
currentDid?: string;
|
|
32
32
|
repoUrls?: Record<string, string>;
|
|
33
|
+
onOpenSettings?: () => void;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export default function NodeDetail({
|
|
@@ -44,6 +45,7 @@ export default function NodeDetail({
|
|
|
44
45
|
isAuthenticated,
|
|
45
46
|
currentDid,
|
|
46
47
|
repoUrls,
|
|
48
|
+
onOpenSettings,
|
|
47
49
|
}: NodeDetailProps) {
|
|
48
50
|
// Reply state — managed here so it's shared across the comment tree
|
|
49
51
|
const [replyingToUri, setReplyingToUri] = useState<string | null>(null);
|
|
@@ -356,7 +358,7 @@ export default function NodeDetail({
|
|
|
356
358
|
|
|
357
359
|
{/* Description expanded modal */}
|
|
358
360
|
{descriptionExpanded && node.description && (
|
|
359
|
-
<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} />
|
|
361
|
+
<DescriptionModal node={node} onClose={() => setDescriptionExpanded(false)} repoUrl={repoUrls?.[node.prefix]} onOpenSettings={onOpenSettings} />
|
|
360
362
|
)}
|
|
361
363
|
|
|
362
364
|
{/* Blocks (issues this blocks) */}
|