@djangocfg/ui-tools 2.1.94 → 2.1.95
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/README.md +129 -0
- package/dist/index.cjs +263 -29
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +103 -1
- package/dist/index.d.ts +103 -1
- package/dist/index.mjs +263 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/components/markdown/MarkdownMessage.tsx +201 -22
- package/src/components/markdown/index.ts +9 -0
- package/src/components/markdown/useCollapsibleContent.ts +236 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/ui-tools",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.95",
|
|
4
4
|
"description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ui-tools",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"consola": "^3.4.2"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@djangocfg/ui-core": "^2.1.
|
|
58
|
+
"@djangocfg/ui-core": "^2.1.95",
|
|
59
59
|
"@rjsf/core": "^6.1.2",
|
|
60
60
|
"@rjsf/utils": "^6.1.2",
|
|
61
61
|
"@rjsf/validator-ajv8": "^6.1.2",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"wavesurfer.js": "^7.12.1"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
76
|
+
"@djangocfg/typescript-config": "^2.1.95",
|
|
77
77
|
"@types/node": "^24.7.2",
|
|
78
78
|
"@types/react": "^19.1.0",
|
|
79
79
|
"@types/react-dom": "^19.1.0",
|
|
@@ -9,6 +9,7 @@ import { useResolvedTheme } from '@djangocfg/ui-core/hooks';
|
|
|
9
9
|
|
|
10
10
|
import Mermaid from '../../tools/Mermaid';
|
|
11
11
|
import PrettyCode from '../../tools/PrettyCode';
|
|
12
|
+
import { useCollapsibleContent } from './useCollapsibleContent';
|
|
12
13
|
|
|
13
14
|
import type { Components } from 'react-markdown';
|
|
14
15
|
|
|
@@ -43,6 +44,43 @@ export interface MarkdownMessageProps {
|
|
|
43
44
|
isUser?: boolean;
|
|
44
45
|
/** Use compact size (text-xs instead of text-sm) */
|
|
45
46
|
isCompact?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Enable collapsible "Read more..." functionality
|
|
49
|
+
* When enabled, long content will be truncated with a toggle button
|
|
50
|
+
* @default false
|
|
51
|
+
*/
|
|
52
|
+
collapsible?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Maximum character length before showing "Read more..."
|
|
55
|
+
* Only applies when collapsible is true
|
|
56
|
+
* If both maxLength and maxLines are set, the stricter limit applies
|
|
57
|
+
*/
|
|
58
|
+
maxLength?: number;
|
|
59
|
+
/**
|
|
60
|
+
* Maximum number of lines before showing "Read more..."
|
|
61
|
+
* Only applies when collapsible is true
|
|
62
|
+
* If both maxLength and maxLines are set, the stricter limit applies
|
|
63
|
+
*/
|
|
64
|
+
maxLines?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Custom "Read more" button text
|
|
67
|
+
* @default "Read more..."
|
|
68
|
+
*/
|
|
69
|
+
readMoreLabel?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Custom "Show less" button text
|
|
72
|
+
* @default "Show less"
|
|
73
|
+
*/
|
|
74
|
+
showLessLabel?: string;
|
|
75
|
+
/**
|
|
76
|
+
* Start expanded (only applies when collapsible is true)
|
|
77
|
+
* @default false
|
|
78
|
+
*/
|
|
79
|
+
defaultExpanded?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* Callback when collapsed state changes
|
|
82
|
+
*/
|
|
83
|
+
onCollapseChange?: (isCollapsed: boolean) => void;
|
|
46
84
|
}
|
|
47
85
|
|
|
48
86
|
// Code block component with copy functionality
|
|
@@ -311,6 +349,82 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
311
349
|
return markdownPatterns.some(pattern => pattern.test(text));
|
|
312
350
|
};
|
|
313
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Read more / Show less toggle button
|
|
354
|
+
*/
|
|
355
|
+
interface CollapseToggleProps {
|
|
356
|
+
isCollapsed: boolean;
|
|
357
|
+
onClick: () => void;
|
|
358
|
+
readMoreLabel: string;
|
|
359
|
+
showLessLabel: string;
|
|
360
|
+
isUser: boolean;
|
|
361
|
+
isCompact: boolean;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const CollapseToggle: React.FC<CollapseToggleProps> = ({
|
|
365
|
+
isCollapsed,
|
|
366
|
+
onClick,
|
|
367
|
+
readMoreLabel,
|
|
368
|
+
showLessLabel,
|
|
369
|
+
isUser,
|
|
370
|
+
isCompact,
|
|
371
|
+
}) => {
|
|
372
|
+
const textSize = isCompact ? 'text-xs' : 'text-sm';
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<button
|
|
376
|
+
type="button"
|
|
377
|
+
onClick={onClick}
|
|
378
|
+
className={`
|
|
379
|
+
${textSize} font-medium cursor-pointer
|
|
380
|
+
transition-colors duration-200
|
|
381
|
+
${isUser
|
|
382
|
+
? 'text-white/80 hover:text-white'
|
|
383
|
+
: 'text-primary hover:text-primary/80'
|
|
384
|
+
}
|
|
385
|
+
inline-flex items-center gap-1
|
|
386
|
+
mt-1
|
|
387
|
+
`}
|
|
388
|
+
>
|
|
389
|
+
{isCollapsed ? (
|
|
390
|
+
<>
|
|
391
|
+
{readMoreLabel}
|
|
392
|
+
<svg
|
|
393
|
+
className="w-3 h-3"
|
|
394
|
+
fill="none"
|
|
395
|
+
stroke="currentColor"
|
|
396
|
+
viewBox="0 0 24 24"
|
|
397
|
+
>
|
|
398
|
+
<path
|
|
399
|
+
strokeLinecap="round"
|
|
400
|
+
strokeLinejoin="round"
|
|
401
|
+
strokeWidth={2}
|
|
402
|
+
d="M19 9l-7 7-7-7"
|
|
403
|
+
/>
|
|
404
|
+
</svg>
|
|
405
|
+
</>
|
|
406
|
+
) : (
|
|
407
|
+
<>
|
|
408
|
+
{showLessLabel}
|
|
409
|
+
<svg
|
|
410
|
+
className="w-3 h-3"
|
|
411
|
+
fill="none"
|
|
412
|
+
stroke="currentColor"
|
|
413
|
+
viewBox="0 0 24 24"
|
|
414
|
+
>
|
|
415
|
+
<path
|
|
416
|
+
strokeLinecap="round"
|
|
417
|
+
strokeLinejoin="round"
|
|
418
|
+
strokeWidth={2}
|
|
419
|
+
d="M5 15l7-7 7 7"
|
|
420
|
+
/>
|
|
421
|
+
</svg>
|
|
422
|
+
</>
|
|
423
|
+
)}
|
|
424
|
+
</button>
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
314
428
|
/**
|
|
315
429
|
* MarkdownMessage - Renders markdown content with syntax highlighting and GFM support
|
|
316
430
|
*
|
|
@@ -321,6 +435,7 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
321
435
|
* - Tables, lists, blockquotes
|
|
322
436
|
* - User/assistant styling modes
|
|
323
437
|
* - Plain text optimization (skips ReactMarkdown for simple text)
|
|
438
|
+
* - Collapsible "Read more..." for long messages
|
|
324
439
|
*
|
|
325
440
|
* @example
|
|
326
441
|
* ```tsx
|
|
@@ -328,6 +443,14 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
328
443
|
*
|
|
329
444
|
* // User message styling
|
|
330
445
|
* <MarkdownMessage content="Some content" isUser />
|
|
446
|
+
*
|
|
447
|
+
* // Collapsible long content (for chat apps)
|
|
448
|
+
* <MarkdownMessage
|
|
449
|
+
* content={longText}
|
|
450
|
+
* collapsible
|
|
451
|
+
* maxLength={300}
|
|
452
|
+
* maxLines={5}
|
|
453
|
+
* />
|
|
331
454
|
* ```
|
|
332
455
|
*/
|
|
333
456
|
export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
@@ -335,48 +458,104 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
335
458
|
className = "",
|
|
336
459
|
isUser = false,
|
|
337
460
|
isCompact = false,
|
|
461
|
+
collapsible = false,
|
|
462
|
+
maxLength,
|
|
463
|
+
maxLines,
|
|
464
|
+
readMoreLabel = "Read more...",
|
|
465
|
+
showLessLabel = "Show less",
|
|
466
|
+
defaultExpanded = false,
|
|
467
|
+
onCollapseChange,
|
|
338
468
|
}) => {
|
|
339
469
|
// Trim content to remove leading/trailing whitespace and empty lines
|
|
340
470
|
const trimmedContent = content.trim();
|
|
341
471
|
|
|
472
|
+
// Collapsible content logic
|
|
473
|
+
const collapsibleOptions = React.useMemo(() => {
|
|
474
|
+
if (!collapsible) return {};
|
|
475
|
+
return { maxLength, maxLines, defaultExpanded };
|
|
476
|
+
}, [collapsible, maxLength, maxLines, defaultExpanded]);
|
|
477
|
+
|
|
478
|
+
const {
|
|
479
|
+
isCollapsed,
|
|
480
|
+
toggleCollapsed,
|
|
481
|
+
displayContent,
|
|
482
|
+
shouldCollapse,
|
|
483
|
+
} = useCollapsibleContent(
|
|
484
|
+
trimmedContent,
|
|
485
|
+
collapsible ? collapsibleOptions : {}
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
// Call onCollapseChange when state changes
|
|
489
|
+
React.useEffect(() => {
|
|
490
|
+
if (collapsible && shouldCollapse && onCollapseChange) {
|
|
491
|
+
onCollapseChange(isCollapsed);
|
|
492
|
+
}
|
|
493
|
+
}, [isCollapsed, collapsible, shouldCollapse, onCollapseChange]);
|
|
494
|
+
|
|
342
495
|
const components = React.useMemo(() => createMarkdownComponents(isUser, isCompact), [isUser, isCompact]);
|
|
343
496
|
|
|
344
497
|
const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
|
|
345
498
|
const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
|
|
346
499
|
|
|
347
500
|
// For plain text without markdown, render directly without ReactMarkdown
|
|
348
|
-
const isPlainText = !hasMarkdownSyntax(
|
|
501
|
+
const isPlainText = !hasMarkdownSyntax(displayContent);
|
|
349
502
|
|
|
503
|
+
// Render plain text
|
|
350
504
|
if (isPlainText) {
|
|
351
505
|
return (
|
|
352
506
|
<span className={`${textSizeClass} leading-relaxed break-words ${className}`}>
|
|
353
|
-
{
|
|
507
|
+
{displayContent}
|
|
508
|
+
{collapsible && shouldCollapse && (
|
|
509
|
+
<>
|
|
510
|
+
{isCollapsed && '... '}
|
|
511
|
+
<CollapseToggle
|
|
512
|
+
isCollapsed={isCollapsed}
|
|
513
|
+
onClick={toggleCollapsed}
|
|
514
|
+
readMoreLabel={readMoreLabel}
|
|
515
|
+
showLessLabel={showLessLabel}
|
|
516
|
+
isUser={isUser}
|
|
517
|
+
isCompact={isCompact}
|
|
518
|
+
/>
|
|
519
|
+
</>
|
|
520
|
+
)}
|
|
354
521
|
</span>
|
|
355
522
|
);
|
|
356
523
|
}
|
|
357
524
|
|
|
525
|
+
// Render markdown
|
|
358
526
|
return (
|
|
359
|
-
<div
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
>
|
|
374
|
-
<ReactMarkdown
|
|
375
|
-
remarkPlugins={[remarkGfm]}
|
|
376
|
-
components={components}
|
|
527
|
+
<div className={className}>
|
|
528
|
+
<div
|
|
529
|
+
className={`
|
|
530
|
+
prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
|
|
531
|
+
${isUser ? 'prose-invert' : 'dark:prose-invert'}
|
|
532
|
+
`}
|
|
533
|
+
style={{
|
|
534
|
+
// Inherit colors from parent - fixes issues with external CSS variables
|
|
535
|
+
'--tw-prose-body': 'inherit',
|
|
536
|
+
'--tw-prose-headings': 'inherit',
|
|
537
|
+
'--tw-prose-bold': 'inherit',
|
|
538
|
+
'--tw-prose-links': 'inherit',
|
|
539
|
+
color: 'inherit',
|
|
540
|
+
} as React.CSSProperties}
|
|
377
541
|
>
|
|
378
|
-
|
|
379
|
-
|
|
542
|
+
<ReactMarkdown
|
|
543
|
+
remarkPlugins={[remarkGfm]}
|
|
544
|
+
components={components}
|
|
545
|
+
>
|
|
546
|
+
{displayContent}
|
|
547
|
+
</ReactMarkdown>
|
|
548
|
+
</div>
|
|
549
|
+
{collapsible && shouldCollapse && (
|
|
550
|
+
<CollapseToggle
|
|
551
|
+
isCollapsed={isCollapsed}
|
|
552
|
+
onClick={toggleCollapsed}
|
|
553
|
+
readMoreLabel={readMoreLabel}
|
|
554
|
+
showLessLabel={showLessLabel}
|
|
555
|
+
isUser={isUser}
|
|
556
|
+
isCompact={isCompact}
|
|
557
|
+
/>
|
|
558
|
+
)}
|
|
380
559
|
</div>
|
|
381
560
|
);
|
|
382
561
|
};
|
|
@@ -3,3 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export { MarkdownMessage, type MarkdownMessageProps } from './MarkdownMessage';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Hooks
|
|
9
|
+
*/
|
|
10
|
+
export {
|
|
11
|
+
useCollapsibleContent,
|
|
12
|
+
type UseCollapsibleContentOptions,
|
|
13
|
+
type UseCollapsibleContentResult,
|
|
14
|
+
} from './useCollapsibleContent';
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useMemo, useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseCollapsibleContentOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Maximum character length before collapsing
|
|
8
|
+
* If both maxLength and maxLines are set, the stricter limit applies
|
|
9
|
+
*/
|
|
10
|
+
maxLength?: number;
|
|
11
|
+
/**
|
|
12
|
+
* Maximum number of lines before collapsing
|
|
13
|
+
* If both maxLength and maxLines are set, the stricter limit applies
|
|
14
|
+
*/
|
|
15
|
+
maxLines?: number;
|
|
16
|
+
/**
|
|
17
|
+
* Start in expanded state (default: false - starts collapsed)
|
|
18
|
+
*/
|
|
19
|
+
defaultExpanded?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseCollapsibleContentResult {
|
|
23
|
+
/** Whether content is currently collapsed */
|
|
24
|
+
isCollapsed: boolean;
|
|
25
|
+
/** Toggle between collapsed/expanded state */
|
|
26
|
+
toggleCollapsed: () => void;
|
|
27
|
+
/** Set collapsed state directly */
|
|
28
|
+
setCollapsed: (collapsed: boolean) => void;
|
|
29
|
+
/** Content to display (truncated if collapsed, full if expanded) */
|
|
30
|
+
displayContent: string;
|
|
31
|
+
/** Whether the content exceeds limits and should be collapsible */
|
|
32
|
+
shouldCollapse: boolean;
|
|
33
|
+
/** Original content length */
|
|
34
|
+
originalLength: number;
|
|
35
|
+
/** Original line count */
|
|
36
|
+
originalLineCount: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Smart truncation that doesn't break words or markdown syntax
|
|
41
|
+
*/
|
|
42
|
+
function smartTruncate(content: string, maxLength: number): string {
|
|
43
|
+
if (content.length <= maxLength) {
|
|
44
|
+
return content;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Find a good break point (space, newline) near maxLength
|
|
48
|
+
let breakPoint = maxLength;
|
|
49
|
+
|
|
50
|
+
// Look backwards for a space or newline
|
|
51
|
+
while (breakPoint > maxLength - 50 && breakPoint > 0) {
|
|
52
|
+
const char = content[breakPoint];
|
|
53
|
+
if (char === ' ' || char === '\n' || char === '\t') {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
breakPoint--;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If we couldn't find a good break point, just use maxLength
|
|
60
|
+
if (breakPoint <= maxLength - 50) {
|
|
61
|
+
breakPoint = maxLength;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let truncated = content.slice(0, breakPoint).trimEnd();
|
|
65
|
+
|
|
66
|
+
// Fix unclosed markdown syntax
|
|
67
|
+
truncated = fixUnclosedMarkdown(truncated);
|
|
68
|
+
|
|
69
|
+
return truncated;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Truncate by line count
|
|
74
|
+
*/
|
|
75
|
+
function truncateByLines(content: string, maxLines: number): string {
|
|
76
|
+
const lines = content.split('\n');
|
|
77
|
+
|
|
78
|
+
if (lines.length <= maxLines) {
|
|
79
|
+
return content;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let truncated = lines.slice(0, maxLines).join('\n').trimEnd();
|
|
83
|
+
|
|
84
|
+
// Fix unclosed markdown syntax
|
|
85
|
+
truncated = fixUnclosedMarkdown(truncated);
|
|
86
|
+
|
|
87
|
+
return truncated;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Fix unclosed markdown syntax to prevent rendering issues
|
|
92
|
+
*/
|
|
93
|
+
function fixUnclosedMarkdown(content: string): string {
|
|
94
|
+
let result = content;
|
|
95
|
+
|
|
96
|
+
// Count occurrences of markdown markers
|
|
97
|
+
const countOccurrences = (str: string, marker: string): number => {
|
|
98
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
99
|
+
const matches = str.match(new RegExp(escaped, 'g'));
|
|
100
|
+
return matches ? matches.length : 0;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Fix unclosed bold (**) - must have even count
|
|
104
|
+
const boldCount = countOccurrences(result, '**');
|
|
105
|
+
if (boldCount % 2 !== 0) {
|
|
106
|
+
result += '**';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Fix unclosed italic (*) - but not **
|
|
110
|
+
// Remove ** first for counting, then count single *
|
|
111
|
+
const withoutBold = result.replace(/\*\*/g, '');
|
|
112
|
+
const italicCount = countOccurrences(withoutBold, '*');
|
|
113
|
+
if (italicCount % 2 !== 0) {
|
|
114
|
+
result += '*';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fix unclosed inline code (`)
|
|
118
|
+
const codeCount = countOccurrences(result, '`');
|
|
119
|
+
// Ignore triple backticks for code blocks
|
|
120
|
+
const tripleCount = countOccurrences(result, '```');
|
|
121
|
+
const singleCodeCount = codeCount - (tripleCount * 3);
|
|
122
|
+
if (singleCodeCount % 2 !== 0) {
|
|
123
|
+
result += '`';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Fix unclosed code blocks (```)
|
|
127
|
+
if (tripleCount % 2 !== 0) {
|
|
128
|
+
result += '\n```';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Fix unclosed strikethrough (~~)
|
|
132
|
+
const strikeCount = countOccurrences(result, '~~');
|
|
133
|
+
if (strikeCount % 2 !== 0) {
|
|
134
|
+
result += '~~';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Fix unclosed underline bold (__)
|
|
138
|
+
const underlineBoldCount = countOccurrences(result, '__');
|
|
139
|
+
if (underlineBoldCount % 2 !== 0) {
|
|
140
|
+
result += '__';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Fix unclosed underline italic (_) - but not __
|
|
144
|
+
const withoutUnderlineBold = result.replace(/__/g, '');
|
|
145
|
+
const underlineItalicCount = countOccurrences(withoutUnderlineBold, '_');
|
|
146
|
+
if (underlineItalicCount % 2 !== 0) {
|
|
147
|
+
result += '_';
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Hook for managing collapsible content with "Read more..." functionality
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* ```tsx
|
|
158
|
+
* const { isCollapsed, toggleCollapsed, displayContent, shouldCollapse } = useCollapsibleContent(
|
|
159
|
+
* longText,
|
|
160
|
+
* { maxLength: 300, maxLines: 5 }
|
|
161
|
+
* );
|
|
162
|
+
*
|
|
163
|
+
* return (
|
|
164
|
+
* <div>
|
|
165
|
+
* <Markdown content={displayContent} />
|
|
166
|
+
* {shouldCollapse && (
|
|
167
|
+
* <button onClick={toggleCollapsed}>
|
|
168
|
+
* {isCollapsed ? 'Read more...' : 'Show less'}
|
|
169
|
+
* </button>
|
|
170
|
+
* )}
|
|
171
|
+
* </div>
|
|
172
|
+
* );
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export function useCollapsibleContent(
|
|
176
|
+
content: string,
|
|
177
|
+
options: UseCollapsibleContentOptions = {}
|
|
178
|
+
): UseCollapsibleContentResult {
|
|
179
|
+
const { maxLength, maxLines, defaultExpanded = false } = options;
|
|
180
|
+
|
|
181
|
+
const [isCollapsed, setIsCollapsed] = useState(!defaultExpanded);
|
|
182
|
+
|
|
183
|
+
const originalLength = content.length;
|
|
184
|
+
const originalLineCount = content.split('\n').length;
|
|
185
|
+
|
|
186
|
+
const { shouldCollapse, truncatedContent } = useMemo(() => {
|
|
187
|
+
// If no limits set, don't collapse
|
|
188
|
+
if (maxLength === undefined && maxLines === undefined) {
|
|
189
|
+
return { shouldCollapse: false, truncatedContent: content };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let needsCollapse = false;
|
|
193
|
+
let result = content;
|
|
194
|
+
|
|
195
|
+
// Check line limit first (usually more restrictive for chat)
|
|
196
|
+
if (maxLines !== undefined && originalLineCount > maxLines) {
|
|
197
|
+
needsCollapse = true;
|
|
198
|
+
result = truncateByLines(result, maxLines);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Then check character limit
|
|
202
|
+
if (maxLength !== undefined && result.length > maxLength) {
|
|
203
|
+
needsCollapse = true;
|
|
204
|
+
result = smartTruncate(result, maxLength);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return { shouldCollapse: needsCollapse, truncatedContent: result };
|
|
208
|
+
}, [content, maxLength, maxLines, originalLineCount]);
|
|
209
|
+
|
|
210
|
+
const displayContent = useMemo(() => {
|
|
211
|
+
if (!shouldCollapse || !isCollapsed) {
|
|
212
|
+
return content;
|
|
213
|
+
}
|
|
214
|
+
return truncatedContent;
|
|
215
|
+
}, [content, truncatedContent, shouldCollapse, isCollapsed]);
|
|
216
|
+
|
|
217
|
+
const toggleCollapsed = useCallback(() => {
|
|
218
|
+
setIsCollapsed((prev) => !prev);
|
|
219
|
+
}, []);
|
|
220
|
+
|
|
221
|
+
const setCollapsed = useCallback((collapsed: boolean) => {
|
|
222
|
+
setIsCollapsed(collapsed);
|
|
223
|
+
}, []);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
isCollapsed,
|
|
227
|
+
toggleCollapsed,
|
|
228
|
+
setCollapsed,
|
|
229
|
+
displayContent,
|
|
230
|
+
shouldCollapse,
|
|
231
|
+
originalLength,
|
|
232
|
+
originalLineCount,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default useCollapsibleContent;
|