@djangocfg/ui-tools 2.1.92 → 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 +267 -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 +267 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/components/markdown/MarkdownMessage.tsx +210 -23
- 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
|
|
@@ -282,8 +320,13 @@ const createMarkdownComponents = (isUser: boolean = false, isCompact: boolean =
|
|
|
282
320
|
),
|
|
283
321
|
};};
|
|
284
322
|
|
|
285
|
-
// Check if content contains markdown syntax
|
|
323
|
+
// Check if content contains markdown syntax or line breaks
|
|
286
324
|
const hasMarkdownSyntax = (text: string): boolean => {
|
|
325
|
+
// If there are line breaks (after trim), treat as markdown for proper paragraph rendering
|
|
326
|
+
if (text.trim().includes('\n')) {
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
|
|
287
330
|
// Common markdown patterns
|
|
288
331
|
const markdownPatterns = [
|
|
289
332
|
/^#{1,6}\s/m, // Headers
|
|
@@ -306,6 +349,82 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
306
349
|
return markdownPatterns.some(pattern => pattern.test(text));
|
|
307
350
|
};
|
|
308
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
|
+
|
|
309
428
|
/**
|
|
310
429
|
* MarkdownMessage - Renders markdown content with syntax highlighting and GFM support
|
|
311
430
|
*
|
|
@@ -316,6 +435,7 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
316
435
|
* - Tables, lists, blockquotes
|
|
317
436
|
* - User/assistant styling modes
|
|
318
437
|
* - Plain text optimization (skips ReactMarkdown for simple text)
|
|
438
|
+
* - Collapsible "Read more..." for long messages
|
|
319
439
|
*
|
|
320
440
|
* @example
|
|
321
441
|
* ```tsx
|
|
@@ -323,6 +443,14 @@ const hasMarkdownSyntax = (text: string): boolean => {
|
|
|
323
443
|
*
|
|
324
444
|
* // User message styling
|
|
325
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
|
+
* />
|
|
326
454
|
* ```
|
|
327
455
|
*/
|
|
328
456
|
export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
@@ -330,45 +458,104 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
|
|
|
330
458
|
className = "",
|
|
331
459
|
isUser = false,
|
|
332
460
|
isCompact = false,
|
|
461
|
+
collapsible = false,
|
|
462
|
+
maxLength,
|
|
463
|
+
maxLines,
|
|
464
|
+
readMoreLabel = "Read more...",
|
|
465
|
+
showLessLabel = "Show less",
|
|
466
|
+
defaultExpanded = false,
|
|
467
|
+
onCollapseChange,
|
|
333
468
|
}) => {
|
|
469
|
+
// Trim content to remove leading/trailing whitespace and empty lines
|
|
470
|
+
const trimmedContent = content.trim();
|
|
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
|
+
|
|
334
495
|
const components = React.useMemo(() => createMarkdownComponents(isUser, isCompact), [isUser, isCompact]);
|
|
335
496
|
|
|
336
497
|
const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
|
|
337
498
|
const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
|
|
338
499
|
|
|
339
500
|
// For plain text without markdown, render directly without ReactMarkdown
|
|
340
|
-
const isPlainText = !hasMarkdownSyntax(
|
|
501
|
+
const isPlainText = !hasMarkdownSyntax(displayContent);
|
|
341
502
|
|
|
503
|
+
// Render plain text
|
|
342
504
|
if (isPlainText) {
|
|
343
505
|
return (
|
|
344
506
|
<span className={`${textSizeClass} leading-relaxed break-words ${className}`}>
|
|
345
|
-
{
|
|
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
|
+
)}
|
|
346
521
|
</span>
|
|
347
522
|
);
|
|
348
523
|
}
|
|
349
524
|
|
|
525
|
+
// Render markdown
|
|
350
526
|
return (
|
|
351
|
-
<div
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
>
|
|
366
|
-
<ReactMarkdown
|
|
367
|
-
remarkPlugins={[remarkGfm]}
|
|
368
|
-
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}
|
|
369
541
|
>
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
)}
|
|
372
559
|
</div>
|
|
373
560
|
);
|
|
374
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;
|