@c-rex/components 0.3.0-build.32 → 0.3.0-build.34
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.34",
|
|
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"));
|
|
@@ -76,14 +86,11 @@ const asLiteralArray = (value: unknown): LiteralModel[] => {
|
|
|
76
86
|
return value.filter((item): item is LiteralModel => Boolean(item && typeof item === "object"));
|
|
77
87
|
};
|
|
78
88
|
|
|
79
|
-
const resolveObjectRefClassLabel = (item: ObjectRefModel, uiLanguage: string): string | undefined => {
|
|
80
|
-
return resolveLiteralLabel(item.class?.labels || [], uiLanguage);
|
|
81
|
-
};
|
|
82
|
-
|
|
83
89
|
const buildMetadataDisplayRows = (
|
|
84
90
|
data: CommonItemsModel,
|
|
85
91
|
uiLanguage: string,
|
|
86
|
-
includeProperties: InformationUnitPropertyKey[]
|
|
92
|
+
includeProperties: InformationUnitPropertyKey[],
|
|
93
|
+
overrides?: MetadataFacetLabelOverrides
|
|
87
94
|
): MetadataDisplayRow[] => {
|
|
88
95
|
const preferredTitle = resolveLiteralLabel(data.titles || [], uiLanguage) || resolveLiteralLabel(data.labels || [], uiLanguage);
|
|
89
96
|
const rows: MetadataDisplayRow[] = [];
|
|
@@ -181,55 +188,37 @@ const buildMetadataDisplayRows = (
|
|
|
181
188
|
const refs = asObjectRefArray(value);
|
|
182
189
|
if (refs.length === 0) continue;
|
|
183
190
|
|
|
184
|
-
|
|
185
|
-
const groupedValues = new Map<string, Set<string>>();
|
|
186
|
-
|
|
187
|
-
refs.forEach((ref) => {
|
|
188
|
-
const sectionLabel = resolveObjectRefClassLabel(ref, uiLanguage);
|
|
189
|
-
const valueLabel = resolveObjectRefLabel(ref, uiLanguage);
|
|
190
|
-
if (!sectionLabel || !valueLabel) return;
|
|
191
|
+
const groupedValues = new Map<string, Set<string>>();
|
|
191
192
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (groupedValues.size > 0) {
|
|
198
|
-
Array.from(groupedValues.entries())
|
|
199
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
200
|
-
.forEach(([sectionLabel, valueSet]) => {
|
|
201
|
-
const values = Array.from(valueSet).sort((a, b) => a.localeCompare(b));
|
|
202
|
-
if (values.length === 0) return;
|
|
203
|
-
|
|
204
|
-
rows.push({
|
|
205
|
-
key,
|
|
206
|
-
label: sectionLabel,
|
|
207
|
-
labelSource: "direct",
|
|
208
|
-
values,
|
|
209
|
-
valueType: "text",
|
|
210
|
-
});
|
|
211
|
-
});
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
}
|
|
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;
|
|
215
198
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
.map((ref) => resolveObjectRefLabel(ref, uiLanguage))
|
|
220
|
-
.filter((label): label is string => Boolean(label))
|
|
221
|
-
)
|
|
222
|
-
).sort((a, b) => a.localeCompare(b));
|
|
199
|
+
const sectionLabel = config.metadataDisplay.sectionStrategy === "none"
|
|
200
|
+
? key
|
|
201
|
+
: override?.sectionLabel || resolveObjectRefClassLabel(ref, uiLanguage) || key;
|
|
223
202
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
key,
|
|
228
|
-
label: key,
|
|
229
|
-
labelSource: "translationKey",
|
|
230
|
-
values,
|
|
231
|
-
valueType: "text",
|
|
203
|
+
const existing = groupedValues.get(sectionLabel) || new Set<string>();
|
|
204
|
+
existing.add(valueLabel);
|
|
205
|
+
groupedValues.set(sectionLabel, existing);
|
|
232
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
|
+
});
|
|
233
222
|
continue;
|
|
234
223
|
}
|
|
235
224
|
|
|
@@ -280,6 +269,7 @@ const AvailableInRow = ({
|
|
|
280
269
|
versionOfShortId?: string | null;
|
|
281
270
|
currentShortId?: string | null;
|
|
282
271
|
linkPattern: string;
|
|
272
|
+
availableInVersions?: AvailableLanguageVersion[];
|
|
283
273
|
}) => {
|
|
284
274
|
const t = useTranslations();
|
|
285
275
|
const locale = useLocale();
|
|
@@ -335,6 +325,8 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
335
325
|
metadataExcludeProperties,
|
|
336
326
|
showBookmarkButton = false,
|
|
337
327
|
showFileRenditions = true,
|
|
328
|
+
metadataLabelOverrides,
|
|
329
|
+
availableInVersions,
|
|
338
330
|
}: Props) => {
|
|
339
331
|
const t = useTranslations();
|
|
340
332
|
const locale = useLocale();
|
|
@@ -347,8 +339,8 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
347
339
|
: [...(metadataExcludeProperties || []), "renditions"],
|
|
348
340
|
});
|
|
349
341
|
|
|
350
|
-
return buildMetadataDisplayRows(data, locale, includeProperties);
|
|
351
|
-
}, [data, locale, metadataExcludeProperties, metadataIncludeProperties, showFileRenditions]);
|
|
342
|
+
return buildMetadataDisplayRows(data, locale, includeProperties, metadataLabelOverrides);
|
|
343
|
+
}, [data, locale, metadataExcludeProperties, metadataIncludeProperties, metadataLabelOverrides, showFileRenditions]);
|
|
352
344
|
|
|
353
345
|
const cardContent = (
|
|
354
346
|
<CardContent className="space-y-3 !p-0">
|
|
@@ -378,11 +370,37 @@ export const InformationUnitMetadataGridClient = ({
|
|
|
378
370
|
</TableRow>
|
|
379
371
|
))}
|
|
380
372
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
+
)}
|
|
386
404
|
</TableBody>
|
|
387
405
|
</Table>
|
|
388
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
|
+
};
|