@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.94",
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.94",
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.94",
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(trimmedContent);
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
- {trimmedContent}
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
- className={`
361
- prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
362
- ${isUser ? 'prose-invert' : 'dark:prose-invert'}
363
- ${className}
364
- `}
365
- style={{
366
- // Inherit colors from parent - fixes issues with external CSS variables
367
- '--tw-prose-body': 'inherit',
368
- '--tw-prose-headings': 'inherit',
369
- '--tw-prose-bold': 'inherit',
370
- '--tw-prose-links': 'inherit',
371
- color: 'inherit',
372
- } as React.CSSProperties}
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
- {trimmedContent}
379
- </ReactMarkdown>
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;