@c-rex/components 0.3.0-build.31 → 0.3.0-build.33
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c-rex/components",
|
|
3
|
-
"version": "0.3.0-build.
|
|
3
|
+
"version": "0.3.0-build.33",
|
|
4
4
|
"files": [
|
|
5
5
|
"src"
|
|
6
6
|
],
|
|
@@ -73,6 +73,22 @@
|
|
|
73
73
|
"types": "./src/article/article-action-bar.tsx",
|
|
74
74
|
"import": "./src/article/article-action-bar.tsx"
|
|
75
75
|
},
|
|
76
|
+
"./toc-browse-controls": {
|
|
77
|
+
"types": "./src/toc/toc-browse-controls.tsx",
|
|
78
|
+
"import": "./src/toc/toc-browse-controls.tsx"
|
|
79
|
+
},
|
|
80
|
+
"./toc-tree-panel": {
|
|
81
|
+
"types": "./src/toc/toc-tree-panel.tsx",
|
|
82
|
+
"import": "./src/toc/toc-tree-panel.tsx"
|
|
83
|
+
},
|
|
84
|
+
"./toc-breadcrumb": {
|
|
85
|
+
"types": "./src/toc/toc-breadcrumb.tsx",
|
|
86
|
+
"import": "./src/toc/toc-breadcrumb.tsx"
|
|
87
|
+
},
|
|
88
|
+
"./toc-types": {
|
|
89
|
+
"types": "./src/toc/types.ts",
|
|
90
|
+
"import": "./src/toc/types.ts"
|
|
91
|
+
},
|
|
76
92
|
"./html-rendition": {
|
|
77
93
|
"types": "./src/renditions/html.tsx",
|
|
78
94
|
"import": "./src/renditions/html.tsx"
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
INFORMATION_UNIT_PROPERTY_PRESENTATION,
|
|
21
21
|
type InformationUnitPropertyKey,
|
|
22
22
|
} from "@c-rex/services/metadata-presentation-config";
|
|
23
|
+
import type { AvailableLanguageVersion, MetadataFacetLabelOverrides } from "@c-rex/services/read-models";
|
|
23
24
|
import { resolveMetadataDisplayProperties } from "@c-rex/services/metadata-view-profile";
|
|
24
25
|
import {
|
|
25
26
|
extractCountryCodeFromLanguage,
|
|
@@ -46,6 +47,8 @@ type Props = {
|
|
|
46
47
|
showBookmarkButton?: boolean;
|
|
47
48
|
showFileRenditions?: boolean;
|
|
48
49
|
embedded?: boolean;
|
|
50
|
+
metadataLabelOverrides?: MetadataFacetLabelOverrides;
|
|
51
|
+
availableInVersions?: AvailableLanguageVersion[];
|
|
49
52
|
};
|
|
50
53
|
|
|
51
54
|
const resolveLiteralLabel = (labels?: LiteralModel[] | null, uiLanguage?: string): string | undefined => {
|
|
@@ -66,6 +69,13 @@ const resolveObjectRefLabel = (item: ObjectRefModel, uiLanguage: string): string
|
|
|
66
69
|
return resolveLiteralLabel(item.labels || [], uiLanguage) || item.shortId || item.id || undefined;
|
|
67
70
|
};
|
|
68
71
|
|
|
72
|
+
const resolveObjectRefClassLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
|
|
73
|
+
return resolveLiteralLabel(item.class?.labels || [], uiLanguage)
|
|
74
|
+
|| item.class?.shortId
|
|
75
|
+
|| item.class?.id
|
|
76
|
+
|| undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
69
79
|
const asObjectRefArray = (value: unknown): ObjectRefModel[] => {
|
|
70
80
|
if (!Array.isArray(value)) return [];
|
|
71
81
|
return value.filter((item): item is ObjectRefModel => Boolean(item && typeof item === "object"));
|
|
@@ -79,7 +89,8 @@ const asLiteralArray = (value: unknown): LiteralModel[] => {
|
|
|
79
89
|
const buildMetadataDisplayRows = (
|
|
80
90
|
data: CommonItemsModel,
|
|
81
91
|
uiLanguage: string,
|
|
82
|
-
includeProperties: InformationUnitPropertyKey[]
|
|
92
|
+
includeProperties: InformationUnitPropertyKey[],
|
|
93
|
+
overrides?: MetadataFacetLabelOverrides
|
|
83
94
|
): MetadataDisplayRow[] => {
|
|
84
95
|
const preferredTitle = resolveLiteralLabel(data.titles || [], uiLanguage) || resolveLiteralLabel(data.labels || [], uiLanguage);
|
|
85
96
|
const rows: MetadataDisplayRow[] = [];
|
|
@@ -177,23 +188,37 @@ const buildMetadataDisplayRows = (
|
|
|
177
188
|
const refs = asObjectRefArray(value);
|
|
178
189
|
if (refs.length === 0) continue;
|
|
179
190
|
|
|
180
|
-
const
|
|
181
|
-
new Set(
|
|
182
|
-
refs
|
|
183
|
-
.map((ref) => resolveObjectRefLabel(ref, uiLanguage))
|
|
184
|
-
.filter((label): label is string => Boolean(label))
|
|
185
|
-
)
|
|
186
|
-
).sort((a, b) => a.localeCompare(b));
|
|
191
|
+
const groupedValues = new Map<string, Set<string>>();
|
|
187
192
|
|
|
188
|
-
|
|
193
|
+
refs.forEach((ref) => {
|
|
194
|
+
const shortId = ref.shortId || "";
|
|
195
|
+
const override = shortId ? overrides?.[key]?.[shortId] : undefined;
|
|
196
|
+
const valueLabel = override?.label || resolveObjectRefLabel(ref, uiLanguage);
|
|
197
|
+
if (!valueLabel) return;
|
|
189
198
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
const sectionLabel = config.metadataDisplay.sectionStrategy === "none"
|
|
200
|
+
? key
|
|
201
|
+
: override?.sectionLabel || resolveObjectRefClassLabel(ref, uiLanguage) || key;
|
|
202
|
+
|
|
203
|
+
const existing = groupedValues.get(sectionLabel) || new Set<string>();
|
|
204
|
+
existing.add(valueLabel);
|
|
205
|
+
groupedValues.set(sectionLabel, existing);
|
|
196
206
|
});
|
|
207
|
+
|
|
208
|
+
Array.from(groupedValues.entries())
|
|
209
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
210
|
+
.forEach(([sectionLabel, valueSet]) => {
|
|
211
|
+
const values = Array.from(valueSet).sort((a, b) => a.localeCompare(b));
|
|
212
|
+
if (values.length === 0) return;
|
|
213
|
+
|
|
214
|
+
rows.push({
|
|
215
|
+
key,
|
|
216
|
+
label: sectionLabel,
|
|
217
|
+
labelSource: sectionLabel === key ? "translationKey" : "direct",
|
|
218
|
+
values,
|
|
219
|
+
valueType: "text",
|
|
220
|
+
});
|
|
221
|
+
});
|
|
197
222
|
continue;
|
|
198
223
|
}
|
|
199
224
|
|
|
@@ -244,6 +269,7 @@ const AvailableInRow = ({
|
|
|
244
269
|
versionOfShortId?: string | null;
|
|
245
270
|
currentShortId?: string | null;
|
|
246
271
|
linkPattern: string;
|
|
272
|
+
availableInVersions?: AvailableLanguageVersion[];
|
|
247
273
|
}) => {
|
|
248
274
|
const t = useTranslations();
|
|
249
275
|
const locale = useLocale();
|
|
@@ -299,6 +325,8 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
299
325
|
metadataExcludeProperties,
|
|
300
326
|
showBookmarkButton = false,
|
|
301
327
|
showFileRenditions = true,
|
|
328
|
+
metadataLabelOverrides,
|
|
329
|
+
availableInVersions,
|
|
302
330
|
}: Props) => {
|
|
303
331
|
const t = useTranslations();
|
|
304
332
|
const locale = useLocale();
|
|
@@ -311,8 +339,8 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
311
339
|
: [...(metadataExcludeProperties || []), "renditions"],
|
|
312
340
|
});
|
|
313
341
|
|
|
314
|
-
return buildMetadataDisplayRows(data, locale, includeProperties);
|
|
315
|
-
}, [data, locale, metadataExcludeProperties, metadataIncludeProperties, showFileRenditions]);
|
|
342
|
+
return buildMetadataDisplayRows(data, locale, includeProperties, metadataLabelOverrides);
|
|
343
|
+
}, [data, locale, metadataExcludeProperties, metadataIncludeProperties, metadataLabelOverrides, showFileRenditions]);
|
|
316
344
|
|
|
317
345
|
const cardContent = (
|
|
318
346
|
<CardContent className="space-y-3 !p-0">
|
|
@@ -342,11 +370,37 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
342
370
|
</TableRow>
|
|
343
371
|
))}
|
|
344
372
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
373
|
+
{availableInVersions ? (
|
|
374
|
+
availableInVersions
|
|
375
|
+
.filter((item) => item.shortId !== data.shortId)
|
|
376
|
+
.length > 0 ? (
|
|
377
|
+
<TableRow className="min-h-12">
|
|
378
|
+
<TableCell className="font-medium w-28 pl-4">
|
|
379
|
+
<h4 className="text-sm font-medium">{t("availableIn")}</h4>
|
|
380
|
+
</TableCell>
|
|
381
|
+
<TableCell className="text-xs text-muted-foreground flex items-center gap-2 min-h-12">
|
|
382
|
+
{availableInVersions
|
|
383
|
+
.filter((item) => item.shortId !== data.shortId)
|
|
384
|
+
.map((item) => (
|
|
385
|
+
<span className="w-8 block border" key={item.shortId}>
|
|
386
|
+
<Link
|
|
387
|
+
href={linkPattern.replace("{shortId}", item.shortId)}
|
|
388
|
+
title={item.language}
|
|
389
|
+
>
|
|
390
|
+
<Flag countryCode={extractCountryCodeFromLanguage(item.language)} />
|
|
391
|
+
</Link>
|
|
392
|
+
</span>
|
|
393
|
+
))}
|
|
394
|
+
</TableCell>
|
|
395
|
+
</TableRow>
|
|
396
|
+
) : null
|
|
397
|
+
) : (
|
|
398
|
+
<AvailableInRow
|
|
399
|
+
versionOfShortId={data.versionOf?.shortId}
|
|
400
|
+
currentShortId={data.shortId}
|
|
401
|
+
linkPattern={linkPattern}
|
|
402
|
+
/>
|
|
403
|
+
)}
|
|
350
404
|
</TableBody>
|
|
351
405
|
</Table>
|
|
352
406
|
</CardContent>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { FC } from "react";
|
|
3
|
+
import {
|
|
4
|
+
Breadcrumb as BreadcrumbComponent,
|
|
5
|
+
BreadcrumbItem,
|
|
6
|
+
BreadcrumbList,
|
|
7
|
+
BreadcrumbPage,
|
|
8
|
+
BreadcrumbSeparator,
|
|
9
|
+
} from "@c-rex/ui/breadcrumb";
|
|
10
|
+
import type { TocBreadcrumbItem } from "./types";
|
|
11
|
+
|
|
12
|
+
type TocBreadcrumbProps = {
|
|
13
|
+
lang: string;
|
|
14
|
+
homeLabel: string;
|
|
15
|
+
homeHref?: string;
|
|
16
|
+
items: TocBreadcrumbItem[];
|
|
17
|
+
className?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const TocBreadcrumb: FC<TocBreadcrumbProps> = ({
|
|
21
|
+
lang,
|
|
22
|
+
homeLabel,
|
|
23
|
+
homeHref = "/",
|
|
24
|
+
items,
|
|
25
|
+
className = "hidden sm:block",
|
|
26
|
+
}) => {
|
|
27
|
+
if (items.length === 0) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<BreadcrumbComponent lang={lang} className={className}>
|
|
33
|
+
<BreadcrumbList>
|
|
34
|
+
<BreadcrumbItem>
|
|
35
|
+
<Link href={homeHref}>{homeLabel}</Link>
|
|
36
|
+
</BreadcrumbItem>
|
|
37
|
+
<BreadcrumbSeparator />
|
|
38
|
+
|
|
39
|
+
{items.map((item, index) => {
|
|
40
|
+
const isLast = item.isCurrent ?? index === items.length - 1;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<span key={`${item.id}-${index}`} className="contents">
|
|
44
|
+
<BreadcrumbItem>
|
|
45
|
+
{isLast ? (
|
|
46
|
+
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
|
47
|
+
) : item.href ? (
|
|
48
|
+
<Link href={item.href}>{item.label}</Link>
|
|
49
|
+
) : (
|
|
50
|
+
<span>{item.label}</span>
|
|
51
|
+
)}
|
|
52
|
+
</BreadcrumbItem>
|
|
53
|
+
{!isLast && <BreadcrumbSeparator />}
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</BreadcrumbList>
|
|
58
|
+
</BreadcrumbComponent>
|
|
59
|
+
);
|
|
60
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
+
import { Button } from "@c-rex/ui/button";
|
|
6
|
+
import { cn } from "@c-rex/utils";
|
|
7
|
+
|
|
8
|
+
export type TocBrowseLink = {
|
|
9
|
+
href: string;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type TocBrowseControlsProps = {
|
|
14
|
+
previous?: TocBrowseLink;
|
|
15
|
+
next?: TocBrowseLink;
|
|
16
|
+
className?: string;
|
|
17
|
+
showLabels?: boolean;
|
|
18
|
+
justify?: "between" | "end";
|
|
19
|
+
buttonGapClassName?: string;
|
|
20
|
+
buttonClassName?: string;
|
|
21
|
+
hideContainerBorder?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const TocBrowseControls = ({
|
|
25
|
+
previous,
|
|
26
|
+
next,
|
|
27
|
+
className,
|
|
28
|
+
showLabels = true,
|
|
29
|
+
justify = "between",
|
|
30
|
+
buttonGapClassName = "gap-2",
|
|
31
|
+
buttonClassName,
|
|
32
|
+
hideContainerBorder = false,
|
|
33
|
+
}: TocBrowseControlsProps) => {
|
|
34
|
+
if (!previous && !next) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className={cn(
|
|
41
|
+
"flex items-center gap-2 rounded-md px-3 py-2",
|
|
42
|
+
hideContainerBorder ? "border-0 bg-transparent" : "border",
|
|
43
|
+
justify === "between" ? "justify-between" : "justify-end",
|
|
44
|
+
className
|
|
45
|
+
)}
|
|
46
|
+
>
|
|
47
|
+
<div className={cn("min-w-0", justify === "between" ? "flex-1" : "flex-none")}>
|
|
48
|
+
{previous ? (
|
|
49
|
+
<Button asChild variant="ghost" className={cn("max-w-full justify-start px-0", buttonClassName)}>
|
|
50
|
+
<Link href={previous.href} title={previous.label} className={cn("inline-flex min-w-0 items-center", buttonGapClassName)}>
|
|
51
|
+
<ChevronLeft className="h-4 w-4 shrink-0" />
|
|
52
|
+
{showLabels ? <span className="truncate">{previous.label}</span> : null}
|
|
53
|
+
</Link>
|
|
54
|
+
</Button>
|
|
55
|
+
) : (
|
|
56
|
+
justify === "between" ? <span className="text-sm text-muted-foreground"> </span> : null
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div className={cn("min-w-0 text-right", justify === "between" ? "flex-1" : "flex-none")}>
|
|
61
|
+
{next ? (
|
|
62
|
+
<Button asChild variant="ghost" className={cn("max-w-full justify-end px-0", buttonClassName)}>
|
|
63
|
+
<Link href={next.href} title={next.label} className={cn("inline-flex min-w-0 items-center", buttonGapClassName)}>
|
|
64
|
+
{showLabels ? <span className="truncate">{next.label}</span> : null}
|
|
65
|
+
<ChevronRight className="h-4 w-4 shrink-0" />
|
|
66
|
+
</Link>
|
|
67
|
+
</Button>
|
|
68
|
+
) : (
|
|
69
|
+
justify === "between" ? <span className="text-sm text-muted-foreground"> </span> : null
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FC, type ReactNode } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarGroup,
|
|
10
|
+
SidebarHeader,
|
|
11
|
+
SidebarMenu,
|
|
12
|
+
SidebarMenuButton,
|
|
13
|
+
SidebarMenuItem,
|
|
14
|
+
SidebarMenuSub,
|
|
15
|
+
} from "@c-rex/ui/sidebar";
|
|
16
|
+
import { Skeleton } from "@c-rex/ui/skeleton";
|
|
17
|
+
import type { TocNodeSummary } from "./types";
|
|
18
|
+
|
|
19
|
+
type TocTreePanelProps = {
|
|
20
|
+
lang: string;
|
|
21
|
+
rootNodeId?: string;
|
|
22
|
+
activePathIds?: string[];
|
|
23
|
+
initialChildrenByParentId?: Record<string, TocNodeSummary[]>;
|
|
24
|
+
placeholder?: ReactNode;
|
|
25
|
+
headerContent?: ReactNode;
|
|
26
|
+
headerTitle?: ReactNode;
|
|
27
|
+
loadChildren: (parentDirectoryNodeShortId: string) => Promise<TocNodeSummary[]>;
|
|
28
|
+
buildNodeHref?: (node: TocNodeSummary) => string | undefined;
|
|
29
|
+
defaultWidthPx?: number;
|
|
30
|
+
minWidthPx?: number;
|
|
31
|
+
maxWidthPx?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const DEFAULT_TOC_WIDTH_PX = 336;
|
|
35
|
+
const MIN_TOC_WIDTH_PX = 260;
|
|
36
|
+
const MAX_TOC_WIDTH_PX = 640;
|
|
37
|
+
|
|
38
|
+
const loadingSkeleton = (
|
|
39
|
+
<div className="pt-4 space-y-2">
|
|
40
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
41
|
+
<Skeleton className="w-auto h-10 mb-2" />
|
|
42
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
43
|
+
<Skeleton className="w-auto h-10 mb-2 ml-8" />
|
|
44
|
+
<div className="px-2 pt-1 text-xs text-muted-foreground">Inhaltsverzeichnis wird geladen...</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export const TocTreePanel: FC<TocTreePanelProps> = ({
|
|
49
|
+
lang,
|
|
50
|
+
rootNodeId,
|
|
51
|
+
activePathIds = [],
|
|
52
|
+
initialChildrenByParentId = {},
|
|
53
|
+
placeholder,
|
|
54
|
+
headerContent,
|
|
55
|
+
headerTitle = "Inhaltsverzeichnis",
|
|
56
|
+
loadChildren,
|
|
57
|
+
buildNodeHref,
|
|
58
|
+
defaultWidthPx = DEFAULT_TOC_WIDTH_PX,
|
|
59
|
+
minWidthPx = MIN_TOC_WIDTH_PX,
|
|
60
|
+
maxWidthPx = MAX_TOC_WIDTH_PX,
|
|
61
|
+
}) => {
|
|
62
|
+
const [childrenByParentId, setChildrenByParentId] = useState<Record<string, TocNodeSummary[]>>(initialChildrenByParentId);
|
|
63
|
+
const [loadingParentIds, setLoadingParentIds] = useState<Set<string>>(new Set());
|
|
64
|
+
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(activePathIds));
|
|
65
|
+
const [tocWidthPx, setTocWidthPx] = useState(defaultWidthPx);
|
|
66
|
+
const resizeStartRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
|
67
|
+
const pendingChildrenRequestsRef = useRef<Map<string, Promise<void>>>(new Map());
|
|
68
|
+
|
|
69
|
+
const activeIdSet = useMemo(() => new Set(activePathIds), [activePathIds]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setChildrenByParentId((prev) => ({
|
|
73
|
+
...prev,
|
|
74
|
+
...initialChildrenByParentId,
|
|
75
|
+
}));
|
|
76
|
+
}, [initialChildrenByParentId]);
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
setExpandedIds(new Set(activePathIds));
|
|
80
|
+
}, [activePathIds]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const knownNodesById = new Map(
|
|
84
|
+
Object.values(childrenByParentId)
|
|
85
|
+
.flat()
|
|
86
|
+
.map((node) => [node.id, node] as const)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expandedIds.forEach((nodeId) => {
|
|
90
|
+
const node = knownNodesById.get(nodeId);
|
|
91
|
+
if (node?.hasChildren && !childrenByParentId[nodeId] && !loadingParentIds.has(nodeId)) {
|
|
92
|
+
void ensureChildrenLoaded(nodeId);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}, [childrenByParentId, expandedIds, loadingParentIds]);
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const handleMove = (event: MouseEvent) => {
|
|
99
|
+
if (!resizeStartRef.current) return;
|
|
100
|
+
const deltaX = event.clientX - resizeStartRef.current.startX;
|
|
101
|
+
const nextWidth = Math.max(
|
|
102
|
+
minWidthPx,
|
|
103
|
+
Math.min(maxWidthPx, resizeStartRef.current.startWidth + deltaX)
|
|
104
|
+
);
|
|
105
|
+
setTocWidthPx(nextWidth);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const handleUp = () => {
|
|
109
|
+
resizeStartRef.current = null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
window.addEventListener("mousemove", handleMove);
|
|
113
|
+
window.addEventListener("mouseup", handleUp);
|
|
114
|
+
|
|
115
|
+
return () => {
|
|
116
|
+
window.removeEventListener("mousemove", handleMove);
|
|
117
|
+
window.removeEventListener("mouseup", handleUp);
|
|
118
|
+
};
|
|
119
|
+
}, [maxWidthPx, minWidthPx]);
|
|
120
|
+
|
|
121
|
+
const ensureChildrenLoaded = async (parentDirectoryNodeShortId: string) => {
|
|
122
|
+
if (!parentDirectoryNodeShortId || childrenByParentId[parentDirectoryNodeShortId] || loadingParentIds.has(parentDirectoryNodeShortId)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const existingRequest = pendingChildrenRequestsRef.current.get(parentDirectoryNodeShortId);
|
|
127
|
+
if (existingRequest) {
|
|
128
|
+
return existingRequest;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const requestPromise = (async () => {
|
|
132
|
+
setLoadingParentIds((prev) => new Set(prev).add(parentDirectoryNodeShortId));
|
|
133
|
+
try {
|
|
134
|
+
const children = await loadChildren(parentDirectoryNodeShortId);
|
|
135
|
+
setChildrenByParentId((prev) => ({
|
|
136
|
+
...prev,
|
|
137
|
+
[parentDirectoryNodeShortId]: children || [],
|
|
138
|
+
}));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
console.error("[TocTreePanel] Failed to load children", error);
|
|
141
|
+
setChildrenByParentId((prev) => ({
|
|
142
|
+
...prev,
|
|
143
|
+
[parentDirectoryNodeShortId]: [],
|
|
144
|
+
}));
|
|
145
|
+
} finally {
|
|
146
|
+
pendingChildrenRequestsRef.current.delete(parentDirectoryNodeShortId);
|
|
147
|
+
setLoadingParentIds((prev) => {
|
|
148
|
+
const next = new Set(prev);
|
|
149
|
+
next.delete(parentDirectoryNodeShortId);
|
|
150
|
+
return next;
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
|
|
155
|
+
pendingChildrenRequestsRef.current.set(parentDirectoryNodeShortId, requestPromise);
|
|
156
|
+
return requestPromise;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const toggleExpanded = (nodeId: string, hasChildren: boolean) => {
|
|
160
|
+
setExpandedIds((prev) => {
|
|
161
|
+
const next = new Set(prev);
|
|
162
|
+
if (next.has(nodeId)) {
|
|
163
|
+
next.delete(nodeId);
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
next.add(nodeId);
|
|
167
|
+
return next;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (hasChildren) {
|
|
171
|
+
void ensureChildrenLoaded(nodeId);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const renderNode = (node: TocNodeSummary): ReactNode => {
|
|
176
|
+
const isExpanded = expandedIds.has(node.id);
|
|
177
|
+
const children = childrenByParentId[node.id] || [];
|
|
178
|
+
const isLoadingChildren = loadingParentIds.has(node.id);
|
|
179
|
+
const href = buildNodeHref?.(node);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<SidebarMenuItem key={node.id}>
|
|
183
|
+
<div className="flex min-w-0 items-center gap-1">
|
|
184
|
+
{node.hasChildren ? (
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
aria-label={isExpanded ? "Collapse section" : "Expand section"}
|
|
188
|
+
className="h-8 w-8 inline-flex items-center justify-center text-muted-foreground hover:text-foreground shrink-0"
|
|
189
|
+
onClick={() => toggleExpanded(node.id, node.hasChildren)}
|
|
190
|
+
>
|
|
191
|
+
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
192
|
+
</button>
|
|
193
|
+
) : (
|
|
194
|
+
<span className="h-8 w-8 shrink-0" aria-hidden="true" />
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{href ? (
|
|
198
|
+
<SidebarMenuButton asChild isActive={activeIdSet.has(node.id)} className="flex-1 min-w-0 whitespace-nowrap">
|
|
199
|
+
<Link href={href} title={node.label} className="w-full min-w-0 overflow-hidden text-left justify-start">
|
|
200
|
+
<span className="truncate">{node.label}</span>
|
|
201
|
+
</Link>
|
|
202
|
+
</SidebarMenuButton>
|
|
203
|
+
) : (
|
|
204
|
+
<SidebarMenuButton
|
|
205
|
+
type="button"
|
|
206
|
+
isActive={activeIdSet.has(node.id)}
|
|
207
|
+
className="flex-1 min-w-0 whitespace-nowrap"
|
|
208
|
+
onClick={() => node.hasChildren && toggleExpanded(node.id, node.hasChildren)}
|
|
209
|
+
title={node.label}
|
|
210
|
+
>
|
|
211
|
+
<span className="truncate">{node.label}</span>
|
|
212
|
+
</SidebarMenuButton>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
{node.hasChildren && isExpanded ? (
|
|
217
|
+
<SidebarMenuSub>
|
|
218
|
+
{isLoadingChildren && children.length === 0 ? (
|
|
219
|
+
<div className="space-y-2 py-2">
|
|
220
|
+
<Skeleton className="h-8 w-full" />
|
|
221
|
+
<Skeleton className="h-8 w-[85%]" />
|
|
222
|
+
</div>
|
|
223
|
+
) : children.map((child) => renderNode(child))}
|
|
224
|
+
</SidebarMenuSub>
|
|
225
|
+
) : null}
|
|
226
|
+
</SidebarMenuItem>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const rootChildren = rootNodeId ? (childrenByParentId[rootNodeId] || []) : [];
|
|
231
|
+
const sidebarStyle = {
|
|
232
|
+
"--sidebar-width": `${tocWidthPx}px`,
|
|
233
|
+
} as CSSProperties;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className="relative shrink-0" style={sidebarStyle}>
|
|
237
|
+
<Sidebar side="left">
|
|
238
|
+
<SidebarHeader className="border-b px-2 py-2">
|
|
239
|
+
<div className="flex min-w-0 w-full items-center justify-between gap-2">
|
|
240
|
+
<div className="min-w-0 overflow-hidden pl-9 text-sm font-medium text-foreground" title={typeof headerTitle === "string" ? headerTitle : undefined}>
|
|
241
|
+
<span className="truncate">{headerTitle}</span>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="ml-auto flex shrink-0 items-center">
|
|
244
|
+
{headerContent}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
</SidebarHeader>
|
|
248
|
+
<SidebarContent className="toc-scrollbar overflow-y-auto overflow-x-hidden">
|
|
249
|
+
<SidebarGroup lang={lang} className="min-w-0">
|
|
250
|
+
{!rootNodeId ? (
|
|
251
|
+
placeholder || null
|
|
252
|
+
) : rootChildren.length === 0 && loadingParentIds.size === 0 ? (
|
|
253
|
+
placeholder || loadingSkeleton
|
|
254
|
+
) : (
|
|
255
|
+
<div className="min-w-0">
|
|
256
|
+
<SidebarMenu className="min-w-0">
|
|
257
|
+
{rootChildren.map((item) => renderNode(item))}
|
|
258
|
+
</SidebarMenu>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
</SidebarGroup>
|
|
262
|
+
</SidebarContent>
|
|
263
|
+
</Sidebar>
|
|
264
|
+
<button
|
|
265
|
+
type="button"
|
|
266
|
+
aria-label="Resize table of contents"
|
|
267
|
+
title="Resize table of contents"
|
|
268
|
+
className="absolute inset-y-0 -right-2 z-20 hidden w-4 cursor-ew-resize lg:block"
|
|
269
|
+
onMouseDown={(event) => {
|
|
270
|
+
resizeStartRef.current = { startX: event.clientX, startWidth: tocWidthPx };
|
|
271
|
+
}}
|
|
272
|
+
onDoubleClick={() => setTocWidthPx(defaultWidthPx)}
|
|
273
|
+
>
|
|
274
|
+
<span className="pointer-events-none absolute inset-y-4 left-1/2 w-px -translate-x-1/2 rounded-full bg-border" />
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
);
|
|
278
|
+
};
|
package/src/toc/types.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type TocNodeSummary = {
|
|
2
|
+
id: string;
|
|
3
|
+
label: string;
|
|
4
|
+
topicId?: string;
|
|
5
|
+
hasChildren: boolean;
|
|
6
|
+
isNavigable: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TocPathEntry = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
topicId?: string;
|
|
13
|
+
isNavigable: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type TocBrowseTarget = {
|
|
17
|
+
directoryNodeShortId: string;
|
|
18
|
+
topicId: string;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type TocBreadcrumbItem = {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
href?: string;
|
|
26
|
+
isCurrent?: boolean;
|
|
27
|
+
};
|