@veluai/velu 0.1.0
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/dist/cli.js +11 -0
- package/package.json +52 -0
- package/runtime/velu-ui/base.css +311 -0
- package/runtime/velu-ui/components/Accordion.jsx +64 -0
- package/runtime/velu-ui/components/ApiClient.jsx +121 -0
- package/runtime/velu-ui/components/ApiField.jsx +87 -0
- package/runtime/velu-ui/components/ApiPath.jsx +63 -0
- package/runtime/velu-ui/components/ApiSidebar.jsx +122 -0
- package/runtime/velu-ui/components/AskBar.jsx +71 -0
- package/runtime/velu-ui/components/Callout.jsx +114 -0
- package/runtime/velu-ui/components/Card.jsx +131 -0
- package/runtime/velu-ui/components/Chatbot.jsx +596 -0
- package/runtime/velu-ui/components/CodeBlock.jsx +375 -0
- package/runtime/velu-ui/components/Columns.jsx +56 -0
- package/runtime/velu-ui/components/Field.jsx +81 -0
- package/runtime/velu-ui/components/Image.jsx +163 -0
- package/runtime/velu-ui/components/MethodBadge.jsx +31 -0
- package/runtime/velu-ui/components/NavSelect.jsx +108 -0
- package/runtime/velu-ui/components/PageFeedback.jsx +219 -0
- package/runtime/velu-ui/components/PageFooter.jsx +213 -0
- package/runtime/velu-ui/components/PageHeader.jsx +414 -0
- package/runtime/velu-ui/components/PageNav.jsx +77 -0
- package/runtime/velu-ui/components/PoweredBy.jsx +51 -0
- package/runtime/velu-ui/components/Prompt.jsx +115 -0
- package/runtime/velu-ui/components/Search.jsx +366 -0
- package/runtime/velu-ui/components/Sidebar.jsx +191 -0
- package/runtime/velu-ui/components/Steps.jsx +65 -0
- package/runtime/velu-ui/components/ThemeToggle.jsx +48 -0
- package/runtime/velu-ui/components/Toc.jsx +537 -0
- package/runtime/velu-ui/components/TocBar.jsx +195 -0
- package/runtime/velu-ui/components/Tree.jsx +87 -0
- package/runtime/velu-ui/components/TryItBar.jsx +90 -0
- package/runtime/velu-ui/components/accordion.css +92 -0
- package/runtime/velu-ui/components/api.css +479 -0
- package/runtime/velu-ui/components/ask-bar.css +94 -0
- package/runtime/velu-ui/components/card.css +105 -0
- package/runtime/velu-ui/components/chatbot.css +617 -0
- package/runtime/velu-ui/components/code-block.css +263 -0
- package/runtime/velu-ui/components/docs-layout.css +775 -0
- package/runtime/velu-ui/components/field.css +82 -0
- package/runtime/velu-ui/components/image.css +237 -0
- package/runtime/velu-ui/components/nav-select.css +157 -0
- package/runtime/velu-ui/components/page-feedback.css +241 -0
- package/runtime/velu-ui/components/page-footer.css +130 -0
- package/runtime/velu-ui/components/page-header.css +520 -0
- package/runtime/velu-ui/components/page-nav.css +50 -0
- package/runtime/velu-ui/components/powered-by.css +66 -0
- package/runtime/velu-ui/components/prompt.css +99 -0
- package/runtime/velu-ui/components/search.css +307 -0
- package/runtime/velu-ui/components/sidebar.css +144 -0
- package/runtime/velu-ui/components/steps.css +77 -0
- package/runtime/velu-ui/components/theme-toggle.css +70 -0
- package/runtime/velu-ui/components/toc-bar.css +234 -0
- package/runtime/velu-ui/components/tree.css +49 -0
- package/runtime/velu-ui/index.js +45 -0
- package/runtime/velu-ui/lib/copyText.js +64 -0
- package/runtime/velu-ui/lib/lang-icons.jsx +156 -0
- package/runtime/velu-ui/lib/prism-langs.js +957 -0
- package/runtime/velu-ui/lib/prism-loader.js +74 -0
- package/runtime/velu-ui/lib/resolveIcon.jsx +29 -0
- package/runtime/velu-ui/lib/scrollIntoNearestView.js +66 -0
- package/runtime/velu-ui/mdx-components.jsx +85 -0
- package/runtime/velu-ui/primitives/Cluster.jsx +49 -0
- package/runtime/velu-ui/primitives/Stack.jsx +63 -0
- package/runtime/velu-ui/primitives/Switcher.jsx +57 -0
- package/runtime/velu-ui/primitives/stack.css +3 -0
- package/runtime/velu-ui/primitives/switcher.css +25 -0
- package/runtime/velu-ui/styles.css +43 -0
- package/runtime/velu-ui/tokens.css +4 -0
- package/schema/velu.schema.json +167 -0
- package/src/navigation.js +434 -0
- package/src/runtime/App.jsx +1473 -0
- package/src/runtime/client-entry.jsx +22 -0
- package/src/runtime/server-entry.jsx +16 -0
- package/src/template.html +48 -0
- package/templates/starter/ai-tools/claude-code.mdx +26 -0
- package/templates/starter/ai-tools/cursor.mdx +17 -0
- package/templates/starter/api-reference/endpoint/create.mdx +24 -0
- package/templates/starter/api-reference/endpoint/get.mdx +27 -0
- package/templates/starter/api-reference/introduction.mdx +28 -0
- package/templates/starter/development.mdx +19 -0
- package/templates/starter/essentials/code.mdx +28 -0
- package/templates/starter/essentials/images.mdx +29 -0
- package/templates/starter/essentials/markdown.mdx +25 -0
- package/templates/starter/essentials/navigation.mdx +39 -0
- package/templates/starter/essentials/settings.mdx +30 -0
- package/templates/starter/favicon.svg +6 -0
- package/templates/starter/index.mdx +31 -0
- package/templates/starter/quickstart.mdx +31 -0
- package/templates/starter/velu.json +33 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import scrollIntoNearestView from '../lib/scrollIntoNearestView.js';
|
|
3
|
+
|
|
4
|
+
const { useState, useRef, useEffect, useMemo } = React;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Toc — "Comet Trail" table of contents (Claude Design v3 port).
|
|
8
|
+
*
|
|
9
|
+
* A bent SVG rail snakes through the (indented) items; a crimson comet head +
|
|
10
|
+
* persistent trail glides to the active section, with an additive hover comet.
|
|
11
|
+
* Faithful to the design's geometry/animation, but:
|
|
12
|
+
* - tokenized: the design's hardcoded colors + its own light/dark THEMES
|
|
13
|
+
* object are dropped; we use velu-ui tokens, so [data-theme] themes it
|
|
14
|
+
* globally (DRY — no per-component theme object).
|
|
15
|
+
* - SSR-safe: getTotalLength/getPointAtLength run only in client effects;
|
|
16
|
+
* first render (server + client) is deterministic (no comet until pathLen
|
|
17
|
+
* is measured), so hydration matches.
|
|
18
|
+
* - presentational + router/scroll-agnostic: takes `items`, `activeId`,
|
|
19
|
+
* `onSelect(id)` — exactly like Sidebar. The consumer owns scroll-spy +
|
|
20
|
+
* smooth-scroll (a scroll container concern, not the component's).
|
|
21
|
+
*
|
|
22
|
+
* Note: this is intentionally NOT a Stack composition — rows are
|
|
23
|
+
* absolutely positioned on a coordinate grid so the SVG path anchors line up
|
|
24
|
+
* with row centers. That coordinate model IS the component; Stack (flow/gap)
|
|
25
|
+
* cannot express it. Legitimately not a primitive use.
|
|
26
|
+
*
|
|
27
|
+
* @typedef {Object} TocItem
|
|
28
|
+
* @property {string} id section id (matches the page element id)
|
|
29
|
+
* @property {string} label
|
|
30
|
+
* @property {TocItem[]} [children]
|
|
31
|
+
*
|
|
32
|
+
* @param {{ items: TocItem[], activeId?: string, onSelect?: (id:string)=>void,
|
|
33
|
+
* rowH?: number, padTop?: number, railLeft?: number, indent?: number,
|
|
34
|
+
* gap?: number, tail?: number, fontSize?: number }} props
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const ACCENT = 'var(--accent-color)';
|
|
38
|
+
// Faint guide rail (the design uses a lighter gray than structural borders);
|
|
39
|
+
// derived from the border token so it still themes — no new token needed.
|
|
40
|
+
const RAIL_BASE = 'color-mix(in srgb, var(--border-color) 45%, transparent)';
|
|
41
|
+
const TEXT = 'var(--text-color)';
|
|
42
|
+
const HALO = 'color-mix(in srgb, var(--accent-color) 22%, transparent)';
|
|
43
|
+
const GLOW = 'color-mix(in srgb, var(--accent-color) 90%, transparent)';
|
|
44
|
+
|
|
45
|
+
// Geometry is token-driven, never hardcoded. The SVG/comet math needs px
|
|
46
|
+
// numbers, so the modular-scale tokens are resolved to px at runtime via a
|
|
47
|
+
// probe element. Fallback = the default scale (s0=16, ratio=1.25) so SSR and
|
|
48
|
+
// the first client render match (no hydration mismatch); the client effect
|
|
49
|
+
// then re-reads the real tokens and auto-adapts if --s0/--ratio are themed.
|
|
50
|
+
const S0 = 16;
|
|
51
|
+
const RATIO = 1.25;
|
|
52
|
+
const scaleStep = (n) => S0 * Math.pow(RATIO, n);
|
|
53
|
+
const DIM_TOKENS = {
|
|
54
|
+
rowH: '--s3',
|
|
55
|
+
padTop: '--s-1',
|
|
56
|
+
railLeft: '--s-1',
|
|
57
|
+
indent: '--s-1',
|
|
58
|
+
fontPx: '--f-h6', // resolved type size; the comet artwork scales off this
|
|
59
|
+
};
|
|
60
|
+
const DIM_FALLBACK = {
|
|
61
|
+
rowH: scaleStep(3),
|
|
62
|
+
padTop: scaleStep(-1),
|
|
63
|
+
railLeft: scaleStep(-1),
|
|
64
|
+
indent: scaleStep(-1),
|
|
65
|
+
fontPx: 13, // --f-h6 clamp resolves ~12–14; 13 = the design's value
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function useScaleDims() {
|
|
69
|
+
const [dims, setDims] = useState(DIM_FALLBACK);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const probe = document.createElement('div');
|
|
72
|
+
probe.style.cssText =
|
|
73
|
+
'position:absolute;visibility:hidden;height:0;pointer-events:none';
|
|
74
|
+
document.body.appendChild(probe);
|
|
75
|
+
const px = (expr) => {
|
|
76
|
+
probe.style.width = expr;
|
|
77
|
+
return parseFloat(getComputedStyle(probe).width);
|
|
78
|
+
};
|
|
79
|
+
const next = {};
|
|
80
|
+
let ok = true;
|
|
81
|
+
for (const k in DIM_TOKENS) {
|
|
82
|
+
const v = px(`var(${DIM_TOKENS[k]})`);
|
|
83
|
+
if (!Number.isFinite(v) || v <= 0) {
|
|
84
|
+
ok = false;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
next[k] = v;
|
|
88
|
+
}
|
|
89
|
+
document.body.removeChild(probe);
|
|
90
|
+
if (ok) setDims(next);
|
|
91
|
+
}, []);
|
|
92
|
+
return dims;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function flatten(nodes, depth = 0, out = []) {
|
|
96
|
+
for (const n of nodes) {
|
|
97
|
+
out.push({
|
|
98
|
+
id: n.id,
|
|
99
|
+
label: n.label,
|
|
100
|
+
depth,
|
|
101
|
+
hasChildren: !!(n.children && n.children.length),
|
|
102
|
+
});
|
|
103
|
+
if (n.children) flatten(n.children, depth + 1, out);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Always-smooth path: each horizontal change is one cubic bezier whose control
|
|
109
|
+
// points sit at the vertical midpoint — rounded elbow regardless of row gap.
|
|
110
|
+
function smoothPath(anchors) {
|
|
111
|
+
if (anchors.length === 0) return '';
|
|
112
|
+
let d = `M ${anchors[0].x} ${anchors[0].y}`;
|
|
113
|
+
for (let i = 1; i < anchors.length; i++) {
|
|
114
|
+
const p = anchors[i - 1];
|
|
115
|
+
const c = anchors[i];
|
|
116
|
+
if (c.x === p.x) d += ` L ${c.x} ${c.y}`;
|
|
117
|
+
else {
|
|
118
|
+
const m = (p.y + c.y) / 2;
|
|
119
|
+
d += ` C ${p.x} ${m}, ${c.x} ${m}, ${c.x} ${c.y}`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return d;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const Row = React.forwardRef(function Row(
|
|
126
|
+
{ item, idx, onEnter, onClick, hovered, active, rowH, indent, textStart, fontSize },
|
|
127
|
+
ref
|
|
128
|
+
) {
|
|
129
|
+
return (
|
|
130
|
+
<div
|
|
131
|
+
ref={ref}
|
|
132
|
+
data-toc-active={active ? 'true' : undefined}
|
|
133
|
+
onMouseEnter={() => onEnter(idx)}
|
|
134
|
+
onClick={() => onClick(item.id)}
|
|
135
|
+
style={{
|
|
136
|
+
/* Natural document flow — rows stack normally so a wrapped row
|
|
137
|
+
PUSHES the rows below it down rather than overlapping them.
|
|
138
|
+
`minHeight: rowH` keeps single-line rows at the canonical
|
|
139
|
+
rhythm; multi-line rows take whatever vertical space they
|
|
140
|
+
need. The SVG rail's anchor y for each row is measured AFTER
|
|
141
|
+
layout (see useEffect in the parent) so the path stays
|
|
142
|
+
aligned with the (possibly grown) row centres. */
|
|
143
|
+
position: 'relative',
|
|
144
|
+
minHeight: rowH,
|
|
145
|
+
display: 'flex',
|
|
146
|
+
alignItems: 'flex-start',
|
|
147
|
+
paddingLeft: textStart + item.depth * indent,
|
|
148
|
+
fontSize,
|
|
149
|
+
color: hovered || active ? ACCENT : TEXT,
|
|
150
|
+
cursor: 'pointer',
|
|
151
|
+
transition: 'color 200ms ease',
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<span
|
|
155
|
+
style={{
|
|
156
|
+
/* Weight is depth-based ONLY — active state is distinguished
|
|
157
|
+
by colour (ACCENT) above, not by a 400→600 weight bump.
|
|
158
|
+
Reason: the bold version is wider, so a label sitting on
|
|
159
|
+
the edge of one line would flip its wrap state every time
|
|
160
|
+
it became active. That changed the measured row height,
|
|
161
|
+
which dragged the SVG anchors around, which made the
|
|
162
|
+
comet appear to wander mid-scroll. Stable weight → stable
|
|
163
|
+
heights → stable rail. */
|
|
164
|
+
fontWeight: item.depth === 0 ? 500 : 400,
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
{item.label}
|
|
168
|
+
</span>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
export default function Toc({
|
|
174
|
+
items = [],
|
|
175
|
+
activeId,
|
|
176
|
+
onSelect = () => {},
|
|
177
|
+
rowH: rowHProp,
|
|
178
|
+
padTop: padTopProp,
|
|
179
|
+
railLeft: railLeftProp,
|
|
180
|
+
indent: indentProp,
|
|
181
|
+
gap = 0,
|
|
182
|
+
tail: tailProp,
|
|
183
|
+
fontSize: fontSizeProp,
|
|
184
|
+
...rest
|
|
185
|
+
}) {
|
|
186
|
+
// Defaults derive from the modular-scale tokens (no hardcoded sizes);
|
|
187
|
+
// callers may still override per-prop.
|
|
188
|
+
const dims = useScaleDims();
|
|
189
|
+
const rowH = rowHProp ?? dims.rowH;
|
|
190
|
+
const padTop = padTopProp ?? dims.padTop;
|
|
191
|
+
const railLeft = railLeftProp ?? dims.railLeft;
|
|
192
|
+
const indent = indentProp ?? dims.indent;
|
|
193
|
+
const tail = tailProp ?? rowH * 6;
|
|
194
|
+
const fontSize = fontSizeProp ?? 'var(--f-h6)';
|
|
195
|
+
|
|
196
|
+
// Comet artwork scales with the type token (dims.fontPx ← --f-h6). These
|
|
197
|
+
// multipliers are proportions of that size, calibrated to reproduce the
|
|
198
|
+
// design exactly at the default (≈13px) — so no raw px literals, and the
|
|
199
|
+
// comet rescales if the type token is themed.
|
|
200
|
+
const fpx = dims.fontPx;
|
|
201
|
+
const cometCoreR = fpx * 0.17;
|
|
202
|
+
const cometHaloR = fpx * 0.42;
|
|
203
|
+
const cometGlow = fpx * 0.31; // px used in blur()/drop-shadow
|
|
204
|
+
const SEL_W = fpx * 0.123;
|
|
205
|
+
const SEL_TAPER = fpx * 0.108;
|
|
206
|
+
const HOV_W = fpx * 0.031;
|
|
207
|
+
const HOV_TAPER = fpx * 0.2;
|
|
208
|
+
const HOV_GLOW_W = fpx * 0.231;
|
|
209
|
+
|
|
210
|
+
const FLAT = useMemo(() => flatten(items), [items]);
|
|
211
|
+
const MAX_DEPTH = useMemo(
|
|
212
|
+
() => FLAT.reduce((m, i) => Math.max(m, i.depth), 0),
|
|
213
|
+
[FLAT]
|
|
214
|
+
);
|
|
215
|
+
const activeIdx = useMemo(() => {
|
|
216
|
+
const i = FLAT.findIndex((f) => f.id === activeId);
|
|
217
|
+
return i === -1 ? null : i;
|
|
218
|
+
}, [FLAT, activeId]);
|
|
219
|
+
|
|
220
|
+
const [hoverIdx, setHoverIdx] = useState(null);
|
|
221
|
+
|
|
222
|
+
// Root container ref — declared up here because the measurement
|
|
223
|
+
// effect below needs it, and a later effect (auto-scroll the active
|
|
224
|
+
// row into the parent scrollport) reuses the same ref.
|
|
225
|
+
const rootRef = useRef(null);
|
|
226
|
+
|
|
227
|
+
// Precomputed anchors — what we use at SSR + before client measurement
|
|
228
|
+
// happens. Once the rows render and we know their actual offsetTop /
|
|
229
|
+
// height (some may have wrapped), we overwrite `anchors` with the
|
|
230
|
+
// measured values so the SVG rail tracks the real row centres.
|
|
231
|
+
const initialAnchors = useMemo(
|
|
232
|
+
() =>
|
|
233
|
+
FLAT.map((item, idx) => ({
|
|
234
|
+
x: railLeft + item.depth * indent,
|
|
235
|
+
y: padTop + idx * rowH + rowH / 2,
|
|
236
|
+
})),
|
|
237
|
+
[FLAT, rowH, padTop, railLeft, indent]
|
|
238
|
+
);
|
|
239
|
+
const [anchors, setAnchors] = useState(initialAnchors);
|
|
240
|
+
const textStart = railLeft + MAX_DEPTH * indent + gap;
|
|
241
|
+
const fullPath = useMemo(() => smoothPath(anchors), [anchors]);
|
|
242
|
+
|
|
243
|
+
// Row refs (collected per render) + remeasure on resize + on relevant
|
|
244
|
+
// dep changes. Using a ResizeObserver on the root catches width
|
|
245
|
+
// changes (which can flip wrap state on borderline labels) and any
|
|
246
|
+
// content-driven height changes (e.g. weight 400 → 600 on active).
|
|
247
|
+
const rowRefs = useRef([]);
|
|
248
|
+
const setRowRef = (idx) => (el) => {
|
|
249
|
+
rowRefs.current[idx] = el;
|
|
250
|
+
};
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
if (typeof window === 'undefined' || !rootRef.current) return;
|
|
253
|
+
const measure = () => {
|
|
254
|
+
const next = FLAT.map((item, idx) => {
|
|
255
|
+
const el = rowRefs.current[idx];
|
|
256
|
+
if (!el) return initialAnchors[idx];
|
|
257
|
+
const top = el.offsetTop;
|
|
258
|
+
const h = el.offsetHeight;
|
|
259
|
+
return {
|
|
260
|
+
x: railLeft + item.depth * indent,
|
|
261
|
+
y: top + h / 2,
|
|
262
|
+
};
|
|
263
|
+
});
|
|
264
|
+
// Short-circuit when nothing changed — same values, same array
|
|
265
|
+
// reference. Prevents the anchorDist useMemo downstream from
|
|
266
|
+
// recomputing and tearing down the comet animation mid-ease.
|
|
267
|
+
setAnchors((prev) => {
|
|
268
|
+
if (
|
|
269
|
+
prev.length === next.length &&
|
|
270
|
+
next.every(
|
|
271
|
+
(a, i) => prev[i] && prev[i].x === a.x && prev[i].y === a.y,
|
|
272
|
+
)
|
|
273
|
+
) {
|
|
274
|
+
return prev;
|
|
275
|
+
}
|
|
276
|
+
return next;
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
measure();
|
|
280
|
+
const ro = new ResizeObserver(measure);
|
|
281
|
+
ro.observe(rootRef.current);
|
|
282
|
+
rowRefs.current.forEach((el) => el && ro.observe(el));
|
|
283
|
+
return () => ro.disconnect();
|
|
284
|
+
// NOTE: deliberately NO `activeId` / `activeIdx` here — those are
|
|
285
|
+
// selection state, not layout. Re-running the measurement on every
|
|
286
|
+
// scroll-spy update would create a new anchors-array reference even
|
|
287
|
+
// when nothing physical changed, which would restart the comet
|
|
288
|
+
// animation every frame (visible as comet jitter).
|
|
289
|
+
}, [FLAT, railLeft, indent, padTop, rowH, initialAnchors]);
|
|
290
|
+
|
|
291
|
+
const pathRef = useRef(null);
|
|
292
|
+
const [pathLen, setPathLen] = useState(0);
|
|
293
|
+
|
|
294
|
+
// Client-only: measure the path once it's in the DOM. (useEffect, not
|
|
295
|
+
// useLayoutEffect, to avoid the SSR warning; brief first-frame settle is ok.)
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (pathRef.current) setPathLen(pathRef.current.getTotalLength());
|
|
298
|
+
}, [fullPath]);
|
|
299
|
+
|
|
300
|
+
const anchorDist = useMemo(() => {
|
|
301
|
+
if (!pathRef.current || !pathLen) return [];
|
|
302
|
+
const samples = 800;
|
|
303
|
+
const pts = [];
|
|
304
|
+
for (let i = 0; i <= samples; i++)
|
|
305
|
+
pts.push(pathRef.current.getPointAtLength((i / samples) * pathLen));
|
|
306
|
+
return anchors.map((a) => {
|
|
307
|
+
let bestI = 0;
|
|
308
|
+
let bestD = Infinity;
|
|
309
|
+
for (let i = 0; i < pts.length; i++) {
|
|
310
|
+
const dx = pts[i].x - a.x;
|
|
311
|
+
const dy = pts[i].y - a.y;
|
|
312
|
+
const d = dx * dx + dy * dy;
|
|
313
|
+
if (d < bestD) {
|
|
314
|
+
bestD = d;
|
|
315
|
+
bestI = i;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return (bestI / samples) * pathLen;
|
|
319
|
+
});
|
|
320
|
+
}, [anchors, pathLen]);
|
|
321
|
+
|
|
322
|
+
const [selHead, setSelHead] = useState(0);
|
|
323
|
+
const [selPt, setSelPt] = useState({ x: 0, y: 0 });
|
|
324
|
+
const [hovHead, setHovHead] = useState(0);
|
|
325
|
+
const [hovPt, setHovPt] = useState({ x: 0, y: 0 });
|
|
326
|
+
|
|
327
|
+
// Scroll-driven selection head glides to the active section.
|
|
328
|
+
useEffect(() => {
|
|
329
|
+
if (activeIdx == null || !anchorDist.length || !pathRef.current) return;
|
|
330
|
+
const dest = anchorDist[activeIdx];
|
|
331
|
+
let raf;
|
|
332
|
+
const start = selHead;
|
|
333
|
+
const t0 = performance.now();
|
|
334
|
+
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
|
335
|
+
const tick = (now) => {
|
|
336
|
+
const t = Math.min(1, (now - t0) / 500);
|
|
337
|
+
const v = start + (dest - start) * ease(t);
|
|
338
|
+
setSelHead(v);
|
|
339
|
+
const pt = pathRef.current.getPointAtLength(v);
|
|
340
|
+
setSelPt({ x: pt.x, y: pt.y });
|
|
341
|
+
if (t < 1) raf = requestAnimationFrame(tick);
|
|
342
|
+
};
|
|
343
|
+
raf = requestAnimationFrame(tick);
|
|
344
|
+
return () => cancelAnimationFrame(raf);
|
|
345
|
+
}, [activeIdx, anchorDist]);
|
|
346
|
+
|
|
347
|
+
// Hover head animates independently; selection trail is never cleared.
|
|
348
|
+
useEffect(() => {
|
|
349
|
+
if (hoverIdx == null || !anchorDist.length || !pathRef.current) return;
|
|
350
|
+
const dest = anchorDist[hoverIdx];
|
|
351
|
+
let raf;
|
|
352
|
+
const start = hovHead || dest;
|
|
353
|
+
const t0 = performance.now();
|
|
354
|
+
const ease = (t) => 1 - Math.pow(1 - t, 3);
|
|
355
|
+
const tick = (now) => {
|
|
356
|
+
const t = Math.min(1, (now - t0) / 280);
|
|
357
|
+
const v = start + (dest - start) * ease(t);
|
|
358
|
+
setHovHead(v);
|
|
359
|
+
const pt = pathRef.current.getPointAtLength(v);
|
|
360
|
+
setHovPt({ x: pt.x, y: pt.y });
|
|
361
|
+
if (t < 1) raf = requestAnimationFrame(tick);
|
|
362
|
+
};
|
|
363
|
+
raf = requestAnimationFrame(tick);
|
|
364
|
+
return () => cancelAnimationFrame(raf);
|
|
365
|
+
}, [hoverIdx, anchorDist]);
|
|
366
|
+
|
|
367
|
+
const hovTailStart = Math.max(0, hovHead - tail);
|
|
368
|
+
const hovVisible = hovHead - hovTailStart;
|
|
369
|
+
|
|
370
|
+
const trail = (headVisible, tailStart, baseW, taper, baseA, aTaper, keyP) => {
|
|
371
|
+
const SEGMENTS = 28;
|
|
372
|
+
const OVERLAP = 0.5;
|
|
373
|
+
const segs = [];
|
|
374
|
+
for (let i = 0; i < SEGMENTS; i++) {
|
|
375
|
+
const t0 = i / SEGMENTS;
|
|
376
|
+
const t1 = (i + 1) / SEGMENTS;
|
|
377
|
+
const segStart = tailStart + headVisible * t0 - (i > 0 ? OVERLAP : 0);
|
|
378
|
+
const segEnd = tailStart + headVisible * t1;
|
|
379
|
+
const dashLen = Math.max(0, segEnd - segStart);
|
|
380
|
+
if (dashLen <= 0) continue;
|
|
381
|
+
const f = t1;
|
|
382
|
+
segs.push(
|
|
383
|
+
<path
|
|
384
|
+
key={`${keyP}${i}`}
|
|
385
|
+
d={fullPath}
|
|
386
|
+
fill="none"
|
|
387
|
+
strokeLinecap="round"
|
|
388
|
+
strokeDasharray={`${dashLen} ${pathLen}`}
|
|
389
|
+
strokeDashoffset={-segStart}
|
|
390
|
+
style={{
|
|
391
|
+
stroke: ACCENT,
|
|
392
|
+
strokeWidth: baseW + Math.pow(f, 1.6) * taper,
|
|
393
|
+
opacity: baseA + Math.pow(f, aTaper) * (0.95 - baseA),
|
|
394
|
+
}}
|
|
395
|
+
/>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
return <g>{segs}</g>;
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
// Keep the active row inside the parent scroll container's visible
|
|
402
|
+
// region — pulling it above the footer-eclipsed band, plus a small
|
|
403
|
+
// safety gap so the active row never sits flush against the footer.
|
|
404
|
+
//
|
|
405
|
+
// The bottom-of-visible-region is computed from a LIVE measurement of
|
|
406
|
+
// the footer's getBoundingClientRect (looked up via `[data-velu-footer]`)
|
|
407
|
+
// rather than from the parent's CSS scroll-padding-bottom. The CSS
|
|
408
|
+
// version is driven by React state that lags one commit behind the
|
|
409
|
+
// user's actual scroll position — at high scroll velocities the active
|
|
410
|
+
// row would otherwise creep under the footer between frames. Reading
|
|
411
|
+
// the footer rect synchronously in the scroll handler is always
|
|
412
|
+
// current with the user's input.
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
if (activeIdx == null) return;
|
|
415
|
+
const BOTTOM_GAP = 32; // px clear between active row and footer top
|
|
416
|
+
const update = () => {
|
|
417
|
+
const el = rootRef.current?.querySelector('[data-toc-active="true"]');
|
|
418
|
+
if (!el) return;
|
|
419
|
+
const footer = document.querySelector('[data-velu-footer]');
|
|
420
|
+
let padBottom;
|
|
421
|
+
if (footer) {
|
|
422
|
+
const rect = footer.getBoundingClientRect();
|
|
423
|
+
const overlap = Math.max(0, window.innerHeight - rect.top);
|
|
424
|
+
padBottom = overlap + BOTTOM_GAP;
|
|
425
|
+
}
|
|
426
|
+
scrollIntoNearestView(
|
|
427
|
+
el,
|
|
428
|
+
padBottom !== undefined ? { padBottom } : undefined,
|
|
429
|
+
);
|
|
430
|
+
};
|
|
431
|
+
update();
|
|
432
|
+
window.addEventListener('scroll', update, { passive: true });
|
|
433
|
+
return () => window.removeEventListener('scroll', update);
|
|
434
|
+
}, [activeIdx]);
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<div
|
|
438
|
+
ref={rootRef}
|
|
439
|
+
onMouseLeave={() => setHoverIdx(null)}
|
|
440
|
+
style={{
|
|
441
|
+
/* Container is naturally sized by the row stack inside —
|
|
442
|
+
wrapped rows extend it. `position: relative` makes the
|
|
443
|
+
absolutely-positioned SVG layer size to this container, and
|
|
444
|
+
gives the rows a stable offsetParent for the measurement
|
|
445
|
+
effect above. padBlock = padTop on both ends matches the
|
|
446
|
+
previous coordinate model's gutter. */
|
|
447
|
+
position: 'relative',
|
|
448
|
+
paddingBlockStart: padTop,
|
|
449
|
+
paddingBlockEnd: padTop,
|
|
450
|
+
}}
|
|
451
|
+
{...rest}
|
|
452
|
+
>
|
|
453
|
+
<svg
|
|
454
|
+
width="100%"
|
|
455
|
+
height="100%"
|
|
456
|
+
style={{
|
|
457
|
+
position: 'absolute',
|
|
458
|
+
inset: 0,
|
|
459
|
+
pointerEvents: 'none',
|
|
460
|
+
overflow: 'visible',
|
|
461
|
+
}}
|
|
462
|
+
>
|
|
463
|
+
<path
|
|
464
|
+
ref={pathRef}
|
|
465
|
+
d={fullPath}
|
|
466
|
+
fill="none"
|
|
467
|
+
style={{ stroke: RAIL_BASE, strokeWidth: 'var(--border-width)' }}
|
|
468
|
+
/>
|
|
469
|
+
|
|
470
|
+
{pathLen > 0 &&
|
|
471
|
+
activeIdx != null &&
|
|
472
|
+
trail(selHead, 0, SEL_W, SEL_TAPER, 0.95, 1, 's')}
|
|
473
|
+
{pathLen > 0 && activeIdx != null && (
|
|
474
|
+
<g>
|
|
475
|
+
<circle cx={selPt.x} cy={selPt.y} r={cometHaloR} style={{ fill: HALO }} />
|
|
476
|
+
<circle
|
|
477
|
+
cx={selPt.x}
|
|
478
|
+
cy={selPt.y}
|
|
479
|
+
r={cometCoreR}
|
|
480
|
+
style={{
|
|
481
|
+
fill: ACCENT,
|
|
482
|
+
filter: `drop-shadow(0 0 ${cometGlow}px ${GLOW})`,
|
|
483
|
+
}}
|
|
484
|
+
/>
|
|
485
|
+
</g>
|
|
486
|
+
)}
|
|
487
|
+
|
|
488
|
+
{pathLen > 0 &&
|
|
489
|
+
hoverIdx != null &&
|
|
490
|
+
trail(hovVisible, hovTailStart, HOV_W, HOV_TAPER, 0.05, 1.3, 'h')}
|
|
491
|
+
{pathLen > 0 && hoverIdx != null && (
|
|
492
|
+
<path
|
|
493
|
+
d={fullPath}
|
|
494
|
+
fill="none"
|
|
495
|
+
strokeLinecap="round"
|
|
496
|
+
strokeDasharray={`${Math.min(hovVisible, tail * 0.5)} ${pathLen}`}
|
|
497
|
+
strokeDashoffset={-(hovHead - Math.min(hovVisible, tail * 0.5))}
|
|
498
|
+
style={{
|
|
499
|
+
stroke: ACCENT,
|
|
500
|
+
strokeWidth: HOV_GLOW_W,
|
|
501
|
+
filter: `blur(${cometGlow}px)`,
|
|
502
|
+
opacity: 0.45,
|
|
503
|
+
}}
|
|
504
|
+
/>
|
|
505
|
+
)}
|
|
506
|
+
{pathLen > 0 && hoverIdx != null && (
|
|
507
|
+
<g>
|
|
508
|
+
<circle cx={hovPt.x} cy={hovPt.y} r={cometHaloR} style={{ fill: HALO }} />
|
|
509
|
+
<circle
|
|
510
|
+
cx={hovPt.x}
|
|
511
|
+
cy={hovPt.y}
|
|
512
|
+
r={cometCoreR}
|
|
513
|
+
style={{ fill: ACCENT, filter: `drop-shadow(0 0 ${cometGlow}px ${GLOW})` }}
|
|
514
|
+
/>
|
|
515
|
+
</g>
|
|
516
|
+
)}
|
|
517
|
+
</svg>
|
|
518
|
+
|
|
519
|
+
{FLAT.map((item, idx) => (
|
|
520
|
+
<Row
|
|
521
|
+
key={item.id}
|
|
522
|
+
ref={setRowRef(idx)}
|
|
523
|
+
item={item}
|
|
524
|
+
idx={idx}
|
|
525
|
+
onEnter={setHoverIdx}
|
|
526
|
+
onClick={onSelect}
|
|
527
|
+
hovered={hoverIdx === idx}
|
|
528
|
+
active={activeIdx === idx}
|
|
529
|
+
rowH={rowH}
|
|
530
|
+
indent={indent}
|
|
531
|
+
textStart={textStart}
|
|
532
|
+
fontSize={fontSize}
|
|
533
|
+
/>
|
|
534
|
+
))}
|
|
535
|
+
</div>
|
|
536
|
+
);
|
|
537
|
+
}
|