@windrun-huaiin/third-ui 14.4.0 → 14.4.2

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.
@@ -1,3 +1,7 @@
1
+ /**
2
+ * References most of its code and SVG animation design from
3
+ * https://github.com/fuma-nama/fumadocs/blob/dev/packages/radix-ui/src/components/toc/clerk.tsx
4
+ */
1
5
  import * as Primitive from 'fumadocs-core/toc';
2
6
  import { type ComponentProps, type ReactNode } from 'react';
3
7
  type TOCItemType = Primitive.TOCItemType;
@@ -37,6 +37,18 @@ const CLERK_TURN_CURVE_HEIGHT = 12;
37
37
  const CLERK_TURN_CONTROL_FACTOR = 0.68;
38
38
  // Safety margin that keeps turns away from the heading rows themselves.
39
39
  const CLERK_TURN_GAP_MARGIN = 7;
40
+ // Shared duration for active rail fade transitions and endpoint dot movement.
41
+ const CLERK_ACTIVE_ANIMATION_DURATION_MS = 300;
42
+ // Easing curve for the active rail and dot; tuned for a slightly delayed, softer motion.
43
+ const CLERK_ACTIVE_ANIMATION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
44
+ // Horizontal gap between the path centerline and the heading text start.
45
+ const CLERK_TEXT_GAP_FROM_PATH = 12;
46
+ // Radius of numbered step badges rendered on top of the path centerline.
47
+ const CLERK_STEP_BADGE_RADIUS = 7;
48
+ // Visual line offsets by grouped heading depth: 1/2, 3, 4, >4.
49
+ const CLERK_DEPTH_GROUP_LINE_OFFSETS = [6, 18, 30, 42];
50
+ // Max number of characters rendered for a TOC label before trimming with ellipsis.
51
+ const CLERK_MAX_LABEL_LENGTH = 44;
40
52
  function PortableClerkTOC({ toc, header, footer, title, emptyLabel = 'No headings', className, }) {
41
53
  return (jsxRuntime.jsxs(page.PageTOC, { className: className, children: [header, title !== null && title !== void 0 ? title : jsxRuntime.jsx(page.PageTOCTitle, {}), jsxRuntime.jsx(PortableClerkTOCScrollArea, { children: jsxRuntime.jsx(PortableClerkTOCItems, { toc: toc, emptyLabel: emptyLabel }) }), footer] }));
42
54
  }
@@ -116,49 +128,85 @@ function PortableClerkTOCItems(_a) {
116
128
  if (toc.length === 0) {
117
129
  return (jsxRuntime.jsx("div", { className: "rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground", children: emptyLabel }));
118
130
  }
119
- return (jsxRuntime.jsxs("div", Object.assign({ ref: mergeRefs(containerRef, ref), className: cn('relative flex flex-col', className) }, props, { children: [jsxRuntime.jsx(ClerkOutline, { path: outlinePath, items: layout.items, activePath: activePath, activeAnchors: activeAnchors, activeEndpoint: activeEndpoint }), metas.map((meta, i) => (jsxRuntime.jsx(PortableClerkTOCItem, { item: meta.item, isActive: activeAnchors.includes(meta.item.url.slice(1)), resolvedContent: meta.resolvedContent, itemPadding: meta.itemPadding, contentRef: (node) => {
131
+ return (jsxRuntime.jsxs("div", Object.assign({ ref: mergeRefs(containerRef, ref), className: cn('relative flex flex-col', className) }, props, { children: [jsxRuntime.jsx(ClerkOutline, { path: outlinePath, items: layout.items, activePath: activePath, activeAnchors: activeAnchors, activeEndpoint: activeEndpoint }), metas.map((meta, i) => (jsxRuntime.jsx(PortableClerkTOCItem, { item: meta.item, isActive: activeAnchors.includes(meta.item.url.slice(1)), resolvedContent: meta.resolvedContent, fullTitle: meta.fullTitle, itemPadding: meta.itemPadding, contentRef: (node) => {
120
132
  contentRefs.current[i] = node;
121
133
  }, ref: (node) => {
122
134
  itemRefs.current[i] = node;
123
135
  } }, meta.item.url)))] })));
124
136
  }
125
- function PortableClerkTOCItem({ item, isActive, resolvedContent, itemPadding, contentRef, ref, }) {
126
- return (jsxRuntime.jsx(Primitive__namespace.TOCItem, { ref: ref, href: item.url, "data-clerk-item": "", style: {
137
+ function PortableClerkTOCItem({ item, isActive, resolvedContent, fullTitle, itemPadding, contentRef, ref, }) {
138
+ return (jsxRuntime.jsx(Primitive__namespace.TOCItem, { ref: ref, href: item.url, "data-clerk-item": "", title: fullTitle !== null && fullTitle !== void 0 ? fullTitle : undefined, style: {
127
139
  paddingInlineStart: itemPadding,
128
- }, className: cn('prose group relative py-1.5 text-sm transition-colors wrap-anywhere first:pt-0 last:pb-0 hover:text-fd-accent-foreground', isActive ? lib.themeIconColor : 'text-fd-muted-foreground'), children: jsxRuntime.jsx("span", { ref: contentRef, className: "relative z-10", children: resolvedContent }) }));
140
+ }, className: cn('prose group relative py-1.5 text-sm transition-colors first:pt-0 last:pb-0 hover:text-fd-accent-foreground', isActive ? lib.themeIconColor : 'text-fd-muted-foreground'), children: jsxRuntime.jsx("span", { ref: contentRef, className: "relative z-10 block overflow-hidden text-ellipsis whitespace-nowrap", children: resolvedContent }) }));
129
141
  }
130
142
  function ClerkOutline({ path, items, activePath, activeAnchors, activeEndpoint, }) {
143
+ const activeSet = new Set(activeAnchors);
144
+ const [displayPath, setDisplayPath] = React.useState(activePath);
145
+ const [fadingPath, setFadingPath] = React.useState(null);
146
+ React.useEffect(() => {
147
+ if (activePath === displayPath)
148
+ return;
149
+ if (!displayPath) {
150
+ setDisplayPath(activePath);
151
+ setFadingPath(null);
152
+ return;
153
+ }
154
+ setFadingPath(displayPath);
155
+ setDisplayPath(activePath);
156
+ const timeout = window.setTimeout(() => {
157
+ setFadingPath(null);
158
+ }, CLERK_ACTIVE_ANIMATION_DURATION_MS);
159
+ return () => window.clearTimeout(timeout);
160
+ }, [activePath, displayPath]);
161
+ const dotTranslate = activeEndpoint
162
+ ? `translate(${activeEndpoint.x - CLERK_ACTIVE_DOT_RADIUS}px, ${activeEndpoint.y - CLERK_ACTIVE_DOT_RADIUS}px)`
163
+ : undefined;
164
+ const transitionStyle = {
165
+ transitionDuration: `${CLERK_ACTIVE_ANIMATION_DURATION_MS}ms`,
166
+ transitionTimingFunction: CLERK_ACTIVE_ANIMATION_EASING,
167
+ };
131
168
  if (!path)
132
169
  return null;
133
- const activeSet = new Set(activeAnchors);
134
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: jsxRuntime.jsx("path", { d: path, className: "stroke-fd-foreground/15", fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round" }) }), jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: activePath ? (jsxRuntime.jsx("path", { d: activePath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: lib.themeSvgIconColor })) : null }), jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: activeEndpoint ? (jsxRuntime.jsx("circle", { cx: activeEndpoint.x, cy: activeEndpoint.y, r: CLERK_ACTIVE_DOT_RADIUS, fill: lib.themeSvgIconColor })) : null }), jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-1 overflow-visible", width: "100%", height: "100%", children: items.map((item) => {
170
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: jsxRuntime.jsx("path", { d: path, className: "stroke-fd-foreground/15", fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round" }) }), jsxRuntime.jsxs("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: [fadingPath ? (jsxRuntime.jsx("path", { d: fadingPath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: lib.themeSvgIconColor, className: "transition-opacity", style: Object.assign({ opacity: 0 }, transitionStyle) })) : null, displayPath ? (jsxRuntime.jsx("path", { d: displayPath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: lib.themeSvgIconColor, className: "transition-opacity", style: Object.assign({ opacity: 1 }, transitionStyle) })) : null] }), jsxRuntime.jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0", children: jsxRuntime.jsx("div", { className: "absolute rounded-full transition-transform", style: Object.assign({ width: CLERK_ACTIVE_DOT_RADIUS * 2, height: CLERK_ACTIVE_DOT_RADIUS * 2, backgroundColor: lib.themeSvgIconColor, transform: dotTranslate, opacity: activeEndpoint ? 1 : 0 }, transitionStyle) }) }), jsxRuntime.jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-1 overflow-visible", width: "100%", height: "100%", children: items.map((item) => {
135
171
  if (!item.stepNumber)
136
172
  return null;
137
173
  const isActive = activeSet.has(item.url.slice(1));
138
- return (jsxRuntime.jsxs("g", { transform: `translate(${item.x}, ${item.y})`, children: [jsxRuntime.jsx("circle", { r: "7", fill: isActive ? lib.themeSvgIconColor : undefined, className: cn(!isActive && 'fill-black dark:fill-white') }), jsxRuntime.jsx("text", { y: "0.5", textAnchor: "middle", dominantBaseline: "middle", className: "fill-white text-[9px] font-medium dark:fill-black", children: item.stepNumber })] }, item.url));
174
+ return (jsxRuntime.jsxs("g", { transform: `translate(${item.x}, ${item.y})`, children: [jsxRuntime.jsx("circle", { r: CLERK_STEP_BADGE_RADIUS, fill: isActive ? lib.themeSvgIconColor : undefined, className: cn(!isActive && 'fill-black dark:fill-white') }), jsxRuntime.jsx("text", { y: "0.5", textAnchor: "middle", dominantBaseline: "middle", className: "fill-white text-[9px] font-medium dark:fill-black", children: item.stepNumber })] }, item.url));
139
175
  }) })] }));
140
176
  }
141
177
  function getItemOffset(depth) {
142
- if (depth <= 2)
143
- return 14;
144
- if (depth === 3)
145
- return 26;
146
- return 36;
178
+ const lineOffset = getLineOffset(depth);
179
+ const badgeRadius = depth === 3 ? CLERK_STEP_BADGE_RADIUS : 0;
180
+ return lineOffset + badgeRadius + CLERK_TEXT_GAP_FROM_PATH;
147
181
  }
148
182
  function getLineOffset(depth) {
149
- return depth >= 3 ? 18 : 6;
183
+ const group = getDepthGroup(depth);
184
+ return CLERK_DEPTH_GROUP_LINE_OFFSETS[group];
150
185
  }
151
186
  function getVisualLinePosition(depth) {
152
187
  return getLineOffset(depth);
153
188
  }
189
+ function getDepthGroup(depth) {
190
+ if (depth <= 2)
191
+ return 0;
192
+ if (depth === 3)
193
+ return 1;
194
+ if (depth === 4)
195
+ return 2;
196
+ return 3;
197
+ }
154
198
  function resolveClerkItem(item) {
155
199
  const isH3 = item.depth === 3;
156
200
  const rawTitle = typeof item.title === 'string' ? item.title : '';
157
201
  const { isStep, displayStep, content } = getStepInfoFromTitle(rawTitle);
158
202
  let stepNumber = isH3 && isStep ? String(displayStep) : null;
159
203
  let resolvedContent = item.title;
204
+ let fullTitle = rawTitle || null;
160
205
  if (isH3 && isStep) {
161
206
  resolvedContent = content !== null && content !== void 0 ? content : item.title;
207
+ if (typeof content === 'string') {
208
+ fullTitle = content;
209
+ }
162
210
  }
163
211
  if (isH3 && !stepNumber) {
164
212
  const urlNum = getDigitsFromUrl(item.url);
@@ -169,18 +217,30 @@ function resolveClerkItem(item) {
169
217
  const match = rawTitle.match(/^(\d+(?:\.\d+)*\.?)\s+(.+)$/);
170
218
  if (match === null || match === void 0 ? void 0 : match[2]) {
171
219
  resolvedContent = match[2];
220
+ fullTitle = match[2];
172
221
  }
173
222
  }
174
223
  }
175
224
  }
225
+ if (typeof resolvedContent === 'string') {
226
+ fullTitle = resolvedContent;
227
+ resolvedContent = truncateClerkLabel(resolvedContent);
228
+ }
176
229
  return {
177
230
  item,
178
231
  resolvedContent,
232
+ fullTitle,
179
233
  stepNumber,
180
234
  itemPadding: getItemOffset(item.depth),
181
235
  lineOffset: getVisualLinePosition(item.depth),
182
236
  };
183
237
  }
238
+ function truncateClerkLabel(value) {
239
+ const normalized = value.trim();
240
+ if (normalized.length <= CLERK_MAX_LABEL_LENGTH)
241
+ return normalized;
242
+ return `${normalized.slice(0, CLERK_MAX_LABEL_LENGTH).trimEnd()}...`;
243
+ }
184
244
  function buildOutlinePath(items) {
185
245
  if (items.length === 0)
186
246
  return '';
@@ -3,7 +3,7 @@ import { __rest } from '../../node_modules/.pnpm/@rollup_plugin-typescript@12.1.
3
3
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
4
4
  import * as Primitive from 'fumadocs-core/toc';
5
5
  import { PageTOC, PageTOCTitle, PageTOCPopover, PageTOCPopoverTrigger, PageTOCPopoverContent } from 'fumadocs-ui/layouts/docs/page';
6
- import { useRef, useState, useMemo, useLayoutEffect } from 'react';
6
+ import { useRef, useState, useMemo, useLayoutEffect, useEffect } from 'react';
7
7
  import { themeSvgIconColor, themeIconColor } from '@windrun-huaiin/base-ui/lib';
8
8
 
9
9
  // Base stroke width for both the inactive rail and the active highlight path.
@@ -16,6 +16,18 @@ const CLERK_TURN_CURVE_HEIGHT = 12;
16
16
  const CLERK_TURN_CONTROL_FACTOR = 0.68;
17
17
  // Safety margin that keeps turns away from the heading rows themselves.
18
18
  const CLERK_TURN_GAP_MARGIN = 7;
19
+ // Shared duration for active rail fade transitions and endpoint dot movement.
20
+ const CLERK_ACTIVE_ANIMATION_DURATION_MS = 300;
21
+ // Easing curve for the active rail and dot; tuned for a slightly delayed, softer motion.
22
+ const CLERK_ACTIVE_ANIMATION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
23
+ // Horizontal gap between the path centerline and the heading text start.
24
+ const CLERK_TEXT_GAP_FROM_PATH = 12;
25
+ // Radius of numbered step badges rendered on top of the path centerline.
26
+ const CLERK_STEP_BADGE_RADIUS = 7;
27
+ // Visual line offsets by grouped heading depth: 1/2, 3, 4, >4.
28
+ const CLERK_DEPTH_GROUP_LINE_OFFSETS = [6, 18, 30, 42];
29
+ // Max number of characters rendered for a TOC label before trimming with ellipsis.
30
+ const CLERK_MAX_LABEL_LENGTH = 44;
19
31
  function PortableClerkTOC({ toc, header, footer, title, emptyLabel = 'No headings', className, }) {
20
32
  return (jsxs(PageTOC, { className: className, children: [header, title !== null && title !== void 0 ? title : jsx(PageTOCTitle, {}), jsx(PortableClerkTOCScrollArea, { children: jsx(PortableClerkTOCItems, { toc: toc, emptyLabel: emptyLabel }) }), footer] }));
21
33
  }
@@ -95,49 +107,85 @@ function PortableClerkTOCItems(_a) {
95
107
  if (toc.length === 0) {
96
108
  return (jsx("div", { className: "rounded-lg border bg-fd-card p-3 text-xs text-fd-muted-foreground", children: emptyLabel }));
97
109
  }
98
- return (jsxs("div", Object.assign({ ref: mergeRefs(containerRef, ref), className: cn('relative flex flex-col', className) }, props, { children: [jsx(ClerkOutline, { path: outlinePath, items: layout.items, activePath: activePath, activeAnchors: activeAnchors, activeEndpoint: activeEndpoint }), metas.map((meta, i) => (jsx(PortableClerkTOCItem, { item: meta.item, isActive: activeAnchors.includes(meta.item.url.slice(1)), resolvedContent: meta.resolvedContent, itemPadding: meta.itemPadding, contentRef: (node) => {
110
+ return (jsxs("div", Object.assign({ ref: mergeRefs(containerRef, ref), className: cn('relative flex flex-col', className) }, props, { children: [jsx(ClerkOutline, { path: outlinePath, items: layout.items, activePath: activePath, activeAnchors: activeAnchors, activeEndpoint: activeEndpoint }), metas.map((meta, i) => (jsx(PortableClerkTOCItem, { item: meta.item, isActive: activeAnchors.includes(meta.item.url.slice(1)), resolvedContent: meta.resolvedContent, fullTitle: meta.fullTitle, itemPadding: meta.itemPadding, contentRef: (node) => {
99
111
  contentRefs.current[i] = node;
100
112
  }, ref: (node) => {
101
113
  itemRefs.current[i] = node;
102
114
  } }, meta.item.url)))] })));
103
115
  }
104
- function PortableClerkTOCItem({ item, isActive, resolvedContent, itemPadding, contentRef, ref, }) {
105
- return (jsx(Primitive.TOCItem, { ref: ref, href: item.url, "data-clerk-item": "", style: {
116
+ function PortableClerkTOCItem({ item, isActive, resolvedContent, fullTitle, itemPadding, contentRef, ref, }) {
117
+ return (jsx(Primitive.TOCItem, { ref: ref, href: item.url, "data-clerk-item": "", title: fullTitle !== null && fullTitle !== void 0 ? fullTitle : undefined, style: {
106
118
  paddingInlineStart: itemPadding,
107
- }, className: cn('prose group relative py-1.5 text-sm transition-colors wrap-anywhere first:pt-0 last:pb-0 hover:text-fd-accent-foreground', isActive ? themeIconColor : 'text-fd-muted-foreground'), children: jsx("span", { ref: contentRef, className: "relative z-10", children: resolvedContent }) }));
119
+ }, className: cn('prose group relative py-1.5 text-sm transition-colors first:pt-0 last:pb-0 hover:text-fd-accent-foreground', isActive ? themeIconColor : 'text-fd-muted-foreground'), children: jsx("span", { ref: contentRef, className: "relative z-10 block overflow-hidden text-ellipsis whitespace-nowrap", children: resolvedContent }) }));
108
120
  }
109
121
  function ClerkOutline({ path, items, activePath, activeAnchors, activeEndpoint, }) {
122
+ const activeSet = new Set(activeAnchors);
123
+ const [displayPath, setDisplayPath] = useState(activePath);
124
+ const [fadingPath, setFadingPath] = useState(null);
125
+ useEffect(() => {
126
+ if (activePath === displayPath)
127
+ return;
128
+ if (!displayPath) {
129
+ setDisplayPath(activePath);
130
+ setFadingPath(null);
131
+ return;
132
+ }
133
+ setFadingPath(displayPath);
134
+ setDisplayPath(activePath);
135
+ const timeout = window.setTimeout(() => {
136
+ setFadingPath(null);
137
+ }, CLERK_ACTIVE_ANIMATION_DURATION_MS);
138
+ return () => window.clearTimeout(timeout);
139
+ }, [activePath, displayPath]);
140
+ const dotTranslate = activeEndpoint
141
+ ? `translate(${activeEndpoint.x - CLERK_ACTIVE_DOT_RADIUS}px, ${activeEndpoint.y - CLERK_ACTIVE_DOT_RADIUS}px)`
142
+ : undefined;
143
+ const transitionStyle = {
144
+ transitionDuration: `${CLERK_ACTIVE_ANIMATION_DURATION_MS}ms`,
145
+ transitionTimingFunction: CLERK_ACTIVE_ANIMATION_EASING,
146
+ };
110
147
  if (!path)
111
148
  return null;
112
- const activeSet = new Set(activeAnchors);
113
- return (jsxs(Fragment, { children: [jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: jsx("path", { d: path, className: "stroke-fd-foreground/15", fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round" }) }), jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: activePath ? (jsx("path", { d: activePath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: themeSvgIconColor })) : null }), jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: activeEndpoint ? (jsx("circle", { cx: activeEndpoint.x, cy: activeEndpoint.y, r: CLERK_ACTIVE_DOT_RADIUS, fill: themeSvgIconColor })) : null }), jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-1 overflow-visible", width: "100%", height: "100%", children: items.map((item) => {
149
+ return (jsxs(Fragment, { children: [jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: jsx("path", { d: path, className: "stroke-fd-foreground/15", fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round" }) }), jsxs("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0 overflow-visible", width: "100%", height: "100%", children: [fadingPath ? (jsx("path", { d: fadingPath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: themeSvgIconColor, className: "transition-opacity", style: Object.assign({ opacity: 0 }, transitionStyle) })) : null, displayPath ? (jsx("path", { d: displayPath, fill: "none", strokeWidth: CLERK_PATH_STROKE_WIDTH, strokeLinecap: "round", strokeLinejoin: "round", stroke: themeSvgIconColor, className: "transition-opacity", style: Object.assign({ opacity: 1 }, transitionStyle) })) : null] }), jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-0", children: jsx("div", { className: "absolute rounded-full transition-transform", style: Object.assign({ width: CLERK_ACTIVE_DOT_RADIUS * 2, height: CLERK_ACTIVE_DOT_RADIUS * 2, backgroundColor: themeSvgIconColor, transform: dotTranslate, opacity: activeEndpoint ? 1 : 0 }, transitionStyle) }) }), jsx("svg", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 z-1 overflow-visible", width: "100%", height: "100%", children: items.map((item) => {
114
150
  if (!item.stepNumber)
115
151
  return null;
116
152
  const isActive = activeSet.has(item.url.slice(1));
117
- return (jsxs("g", { transform: `translate(${item.x}, ${item.y})`, children: [jsx("circle", { r: "7", fill: isActive ? themeSvgIconColor : undefined, className: cn(!isActive && 'fill-black dark:fill-white') }), jsx("text", { y: "0.5", textAnchor: "middle", dominantBaseline: "middle", className: "fill-white text-[9px] font-medium dark:fill-black", children: item.stepNumber })] }, item.url));
153
+ return (jsxs("g", { transform: `translate(${item.x}, ${item.y})`, children: [jsx("circle", { r: CLERK_STEP_BADGE_RADIUS, fill: isActive ? themeSvgIconColor : undefined, className: cn(!isActive && 'fill-black dark:fill-white') }), jsx("text", { y: "0.5", textAnchor: "middle", dominantBaseline: "middle", className: "fill-white text-[9px] font-medium dark:fill-black", children: item.stepNumber })] }, item.url));
118
154
  }) })] }));
119
155
  }
120
156
  function getItemOffset(depth) {
121
- if (depth <= 2)
122
- return 14;
123
- if (depth === 3)
124
- return 26;
125
- return 36;
157
+ const lineOffset = getLineOffset(depth);
158
+ const badgeRadius = depth === 3 ? CLERK_STEP_BADGE_RADIUS : 0;
159
+ return lineOffset + badgeRadius + CLERK_TEXT_GAP_FROM_PATH;
126
160
  }
127
161
  function getLineOffset(depth) {
128
- return depth >= 3 ? 18 : 6;
162
+ const group = getDepthGroup(depth);
163
+ return CLERK_DEPTH_GROUP_LINE_OFFSETS[group];
129
164
  }
130
165
  function getVisualLinePosition(depth) {
131
166
  return getLineOffset(depth);
132
167
  }
168
+ function getDepthGroup(depth) {
169
+ if (depth <= 2)
170
+ return 0;
171
+ if (depth === 3)
172
+ return 1;
173
+ if (depth === 4)
174
+ return 2;
175
+ return 3;
176
+ }
133
177
  function resolveClerkItem(item) {
134
178
  const isH3 = item.depth === 3;
135
179
  const rawTitle = typeof item.title === 'string' ? item.title : '';
136
180
  const { isStep, displayStep, content } = getStepInfoFromTitle(rawTitle);
137
181
  let stepNumber = isH3 && isStep ? String(displayStep) : null;
138
182
  let resolvedContent = item.title;
183
+ let fullTitle = rawTitle || null;
139
184
  if (isH3 && isStep) {
140
185
  resolvedContent = content !== null && content !== void 0 ? content : item.title;
186
+ if (typeof content === 'string') {
187
+ fullTitle = content;
188
+ }
141
189
  }
142
190
  if (isH3 && !stepNumber) {
143
191
  const urlNum = getDigitsFromUrl(item.url);
@@ -148,18 +196,30 @@ function resolveClerkItem(item) {
148
196
  const match = rawTitle.match(/^(\d+(?:\.\d+)*\.?)\s+(.+)$/);
149
197
  if (match === null || match === void 0 ? void 0 : match[2]) {
150
198
  resolvedContent = match[2];
199
+ fullTitle = match[2];
151
200
  }
152
201
  }
153
202
  }
154
203
  }
204
+ if (typeof resolvedContent === 'string') {
205
+ fullTitle = resolvedContent;
206
+ resolvedContent = truncateClerkLabel(resolvedContent);
207
+ }
155
208
  return {
156
209
  item,
157
210
  resolvedContent,
211
+ fullTitle,
158
212
  stepNumber,
159
213
  itemPadding: getItemOffset(item.depth),
160
214
  lineOffset: getVisualLinePosition(item.depth),
161
215
  };
162
216
  }
217
+ function truncateClerkLabel(value) {
218
+ const normalized = value.trim();
219
+ if (normalized.length <= CLERK_MAX_LABEL_LENGTH)
220
+ return normalized;
221
+ return `${normalized.slice(0, CLERK_MAX_LABEL_LENGTH).trimEnd()}...`;
222
+ }
163
223
  function buildOutlinePath(items) {
164
224
  if (items.length === 0)
165
225
  return '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@windrun-huaiin/third-ui",
3
- "version": "14.4.0",
3
+ "version": "14.4.2",
4
4
  "description": "Third-party integrated UI components for windrun-huaiin projects",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -1,5 +1,9 @@
1
1
  'use client';
2
2
 
3
+ /**
4
+ * References most of its code and SVG animation design from
5
+ * https://github.com/fuma-nama/fumadocs/blob/dev/packages/radix-ui/src/components/toc/clerk.tsx
6
+ */
3
7
  import * as Primitive from 'fumadocs-core/toc';
4
8
  import {
5
9
  PageTOC,
@@ -11,6 +15,7 @@ import {
11
15
  import {
12
16
  type ComponentProps,
13
17
  type ReactNode,
18
+ useEffect,
14
19
  useLayoutEffect,
15
20
  useMemo,
16
21
  useRef,
@@ -35,6 +40,7 @@ type PortableClerkTOCProps = {
35
40
  type ClerkItemMeta = {
36
41
  item: TOCItemType;
37
42
  resolvedContent: ReactNode;
43
+ fullTitle: string | null;
38
44
  stepNumber: string | null;
39
45
  itemPadding: number;
40
46
  lineOffset: number;
@@ -57,6 +63,18 @@ const CLERK_TURN_CURVE_HEIGHT = 12;
57
63
  const CLERK_TURN_CONTROL_FACTOR = 0.68;
58
64
  // Safety margin that keeps turns away from the heading rows themselves.
59
65
  const CLERK_TURN_GAP_MARGIN = 7;
66
+ // Shared duration for active rail fade transitions and endpoint dot movement.
67
+ const CLERK_ACTIVE_ANIMATION_DURATION_MS = 300;
68
+ // Easing curve for the active rail and dot; tuned for a slightly delayed, softer motion.
69
+ const CLERK_ACTIVE_ANIMATION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
70
+ // Horizontal gap between the path centerline and the heading text start.
71
+ const CLERK_TEXT_GAP_FROM_PATH = 12;
72
+ // Radius of numbered step badges rendered on top of the path centerline.
73
+ const CLERK_STEP_BADGE_RADIUS = 7;
74
+ // Visual line offsets by grouped heading depth: 1/2, 3, 4, >4.
75
+ const CLERK_DEPTH_GROUP_LINE_OFFSETS = [6, 18, 30, 42] as const;
76
+ // Max number of characters rendered for a TOC label before trimming with ellipsis.
77
+ const CLERK_MAX_LABEL_LENGTH = 44;
60
78
 
61
79
  export function PortableClerkTOC({
62
80
  toc,
@@ -237,6 +255,7 @@ export function PortableClerkTOCItems({
237
255
  item={meta.item}
238
256
  isActive={activeAnchors.includes(meta.item.url.slice(1))}
239
257
  resolvedContent={meta.resolvedContent}
258
+ fullTitle={meta.fullTitle}
240
259
  itemPadding={meta.itemPadding}
241
260
  contentRef={(node: HTMLSpanElement | null) => {
242
261
  contentRefs.current[i] = node;
@@ -254,6 +273,7 @@ function PortableClerkTOCItem({
254
273
  item,
255
274
  isActive,
256
275
  resolvedContent,
276
+ fullTitle,
257
277
  itemPadding,
258
278
  contentRef,
259
279
  ref,
@@ -261,6 +281,7 @@ function PortableClerkTOCItem({
261
281
  item: TOCItemType;
262
282
  isActive: boolean;
263
283
  resolvedContent: ReactNode;
284
+ fullTitle: string | null;
264
285
  itemPadding: number;
265
286
  contentRef?: ((node: HTMLSpanElement | null) => void) | null;
266
287
  ref?: ((node: HTMLAnchorElement | null) => void) | null;
@@ -270,15 +291,19 @@ function PortableClerkTOCItem({
270
291
  ref={ref}
271
292
  href={item.url}
272
293
  data-clerk-item=""
294
+ title={fullTitle ?? undefined}
273
295
  style={{
274
296
  paddingInlineStart: itemPadding,
275
297
  }}
276
298
  className={cn(
277
- 'prose group relative py-1.5 text-sm transition-colors wrap-anywhere first:pt-0 last:pb-0 hover:text-fd-accent-foreground',
299
+ 'prose group relative py-1.5 text-sm transition-colors first:pt-0 last:pb-0 hover:text-fd-accent-foreground',
278
300
  isActive ? themeIconColor : 'text-fd-muted-foreground',
279
301
  )}
280
302
  >
281
- <span ref={contentRef} className="relative z-10">
303
+ <span
304
+ ref={contentRef}
305
+ className="relative z-10 block overflow-hidden text-ellipsis whitespace-nowrap"
306
+ >
282
307
  {resolvedContent}
283
308
  </span>
284
309
  </Primitive.TOCItem>
@@ -298,9 +323,39 @@ function ClerkOutline({
298
323
  activeAnchors: string[];
299
324
  activeEndpoint: { x: number; y: number } | null;
300
325
  }) {
301
- if (!path) return null;
302
-
303
326
  const activeSet = new Set(activeAnchors);
327
+ const [displayPath, setDisplayPath] = useState(activePath);
328
+ const [fadingPath, setFadingPath] = useState<string | null>(null);
329
+
330
+ useEffect(() => {
331
+ if (activePath === displayPath) return;
332
+ if (!displayPath) {
333
+ setDisplayPath(activePath);
334
+ setFadingPath(null);
335
+ return;
336
+ }
337
+
338
+ setFadingPath(displayPath);
339
+ setDisplayPath(activePath);
340
+
341
+ const timeout = window.setTimeout(() => {
342
+ setFadingPath(null);
343
+ }, CLERK_ACTIVE_ANIMATION_DURATION_MS);
344
+
345
+ return () => window.clearTimeout(timeout);
346
+ }, [activePath, displayPath]);
347
+
348
+ const dotTranslate = activeEndpoint
349
+ ? `translate(${activeEndpoint.x - CLERK_ACTIVE_DOT_RADIUS}px, ${
350
+ activeEndpoint.y - CLERK_ACTIVE_DOT_RADIUS
351
+ }px)`
352
+ : undefined;
353
+ const transitionStyle = {
354
+ transitionDuration: `${CLERK_ACTIVE_ANIMATION_DURATION_MS}ms`,
355
+ transitionTimingFunction: CLERK_ACTIVE_ANIMATION_EASING,
356
+ };
357
+
358
+ if (!path) return null;
304
359
 
305
360
  return (
306
361
  <>
@@ -325,32 +380,50 @@ function ClerkOutline({
325
380
  width="100%"
326
381
  height="100%"
327
382
  >
328
- {activePath ? (
383
+ {fadingPath ? (
329
384
  <path
330
- d={activePath}
385
+ d={fadingPath}
331
386
  fill="none"
332
387
  strokeWidth={CLERK_PATH_STROKE_WIDTH}
333
388
  strokeLinecap="round"
334
389
  strokeLinejoin="round"
335
390
  stroke={themeSvgIconColor}
391
+ className="transition-opacity"
392
+ style={{
393
+ opacity: 0,
394
+ ...transitionStyle,
395
+ }}
336
396
  />
337
397
  ) : null}
338
- </svg>
339
- <svg
340
- aria-hidden="true"
341
- className="pointer-events-none absolute inset-0 z-0 overflow-visible"
342
- width="100%"
343
- height="100%"
344
- >
345
- {activeEndpoint ? (
346
- <circle
347
- cx={activeEndpoint.x}
348
- cy={activeEndpoint.y}
349
- r={CLERK_ACTIVE_DOT_RADIUS}
350
- fill={themeSvgIconColor}
398
+ {displayPath ? (
399
+ <path
400
+ d={displayPath}
401
+ fill="none"
402
+ strokeWidth={CLERK_PATH_STROKE_WIDTH}
403
+ strokeLinecap="round"
404
+ strokeLinejoin="round"
405
+ stroke={themeSvgIconColor}
406
+ className="transition-opacity"
407
+ style={{
408
+ opacity: 1,
409
+ ...transitionStyle,
410
+ }}
351
411
  />
352
412
  ) : null}
353
413
  </svg>
414
+ <div aria-hidden="true" className="pointer-events-none absolute inset-0 z-0">
415
+ <div
416
+ className="absolute rounded-full transition-transform"
417
+ style={{
418
+ width: CLERK_ACTIVE_DOT_RADIUS * 2,
419
+ height: CLERK_ACTIVE_DOT_RADIUS * 2,
420
+ backgroundColor: themeSvgIconColor,
421
+ transform: dotTranslate,
422
+ opacity: activeEndpoint ? 1 : 0,
423
+ ...transitionStyle,
424
+ }}
425
+ />
426
+ </div>
354
427
  <svg
355
428
  aria-hidden="true"
356
429
  className="pointer-events-none absolute inset-0 z-1 overflow-visible"
@@ -365,7 +438,7 @@ function ClerkOutline({
365
438
  return (
366
439
  <g key={item.url} transform={`translate(${item.x}, ${item.y})`}>
367
440
  <circle
368
- r="7"
441
+ r={CLERK_STEP_BADGE_RADIUS}
369
442
  fill={isActive ? themeSvgIconColor : undefined}
370
443
  className={cn(!isActive && 'fill-black dark:fill-white')}
371
444
  />
@@ -386,28 +459,40 @@ function ClerkOutline({
386
459
  }
387
460
 
388
461
  function getItemOffset(depth: number): number {
389
- if (depth <= 2) return 14;
390
- if (depth === 3) return 26;
391
- return 36;
462
+ const lineOffset = getLineOffset(depth);
463
+ const badgeRadius = depth === 3 ? CLERK_STEP_BADGE_RADIUS : 0;
464
+ return lineOffset + badgeRadius + CLERK_TEXT_GAP_FROM_PATH;
392
465
  }
393
466
 
394
467
  function getLineOffset(depth: number): number {
395
- return depth >= 3 ? 18 : 6;
468
+ const group = getDepthGroup(depth);
469
+ return CLERK_DEPTH_GROUP_LINE_OFFSETS[group];
396
470
  }
397
471
 
398
472
  function getVisualLinePosition(depth: number): number {
399
473
  return getLineOffset(depth);
400
474
  }
401
475
 
476
+ function getDepthGroup(depth: number): number {
477
+ if (depth <= 2) return 0;
478
+ if (depth === 3) return 1;
479
+ if (depth === 4) return 2;
480
+ return 3;
481
+ }
482
+
402
483
  function resolveClerkItem(item: TOCItemType): ClerkItemMeta {
403
484
  const isH3 = item.depth === 3;
404
485
  const rawTitle = typeof item.title === 'string' ? item.title : '';
405
486
  const { isStep, displayStep, content } = getStepInfoFromTitle(rawTitle);
406
487
  let stepNumber: string | null = isH3 && isStep ? String(displayStep) : null;
407
488
  let resolvedContent: ReactNode = item.title;
489
+ let fullTitle: string | null = rawTitle || null;
408
490
 
409
491
  if (isH3 && isStep) {
410
492
  resolvedContent = content ?? item.title;
493
+ if (typeof content === 'string') {
494
+ fullTitle = content;
495
+ }
411
496
  }
412
497
 
413
498
  if (isH3 && !stepNumber) {
@@ -419,20 +504,34 @@ function resolveClerkItem(item: TOCItemType): ClerkItemMeta {
419
504
  const match = rawTitle.match(/^(\d+(?:\.\d+)*\.?)\s+(.+)$/);
420
505
  if (match?.[2]) {
421
506
  resolvedContent = match[2];
507
+ fullTitle = match[2];
422
508
  }
423
509
  }
424
510
  }
425
511
  }
426
512
 
513
+ if (typeof resolvedContent === 'string') {
514
+ fullTitle = resolvedContent;
515
+ resolvedContent = truncateClerkLabel(resolvedContent);
516
+ }
517
+
427
518
  return {
428
519
  item,
429
520
  resolvedContent,
521
+ fullTitle,
430
522
  stepNumber,
431
523
  itemPadding: getItemOffset(item.depth),
432
524
  lineOffset: getVisualLinePosition(item.depth),
433
525
  };
434
526
  }
435
527
 
528
+ function truncateClerkLabel(value: string): string {
529
+ const normalized = value.trim();
530
+ if (normalized.length <= CLERK_MAX_LABEL_LENGTH) return normalized;
531
+
532
+ return `${normalized.slice(0, CLERK_MAX_LABEL_LENGTH).trimEnd()}...`;
533
+ }
534
+
436
535
  function buildOutlinePath(items: ClerkItemMeasure[]): string {
437
536
  if (items.length === 0) return '';
438
537
 
@@ -614,7 +713,7 @@ function mergeRefs<T>(
614
713
  }
615
714
 
616
715
  try {
617
- (ref as React.MutableRefObject<T | null>).current = node;
716
+ (ref as React.RefObject<T | null>).current = node;
618
717
  } catch {
619
718
  // ignore readonly refs
620
719
  }
@@ -3,6 +3,19 @@
3
3
  @apply top-20!;
4
4
  }
5
5
 
6
+ @media (min-width: 1280px) {
7
+ /* 给桌面端固定 TOC 留一点底部呼吸感,避免视觉上直接压到 footer */
8
+ #nd-toc {
9
+ bottom: 1.25rem !important;
10
+ }
11
+
12
+ /* 文档主区域补一小段底部缓冲,减少 footer 刚进入视口就与 TOC 相撞 */
13
+ #nd-page {
14
+ padding-bottom: 5rem !important;
15
+ min-height: calc(100dvh - 5rem) !important;
16
+ }
17
+ }
18
+
6
19
  /* 移动端文章目录下拉框的边距,建议移动端不要目录导航 96px */
7
20
  #nd-tocnav {
8
21
  @apply top-24!;
@@ -35,7 +48,7 @@ button[aria-label="Open Search"][data-search=""] {
35
48
 
36
49
  /* Custome Fuma Steps */
37
50
  .fd-step::before {
38
- @apply size-5 -start-2.5 rounded-full;
51
+ @apply size-5 -inset-s-2.5 rounded-full;
39
52
  background-color: #000;
40
53
  color: #fff;
41
54
  font-size: 0.75rem;
@@ -128,4 +141,4 @@ button[aria-label="Open Search"][data-search=""] {
128
141
  [data-rmiz-modal-img] {
129
142
  transition-duration: 0.01ms !important;
130
143
  }
131
- }
144
+ }