@djangocfg/ui-tools 2.1.94 → 2.1.96

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.96",
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.96",
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.96",
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,107 @@ 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 - use defaults when collapsible is enabled
473
+ const collapsibleOptions = React.useMemo(() => {
474
+ if (!collapsible) return {};
475
+ // Default limits when collapsible is enabled but no limits specified
476
+ const effectiveMaxLength = maxLength ?? 1000;
477
+ const effectiveMaxLines = maxLines ?? 10;
478
+ return { maxLength: effectiveMaxLength, maxLines: effectiveMaxLines, defaultExpanded };
479
+ }, [collapsible, maxLength, maxLines, defaultExpanded]);
480
+
481
+ const {
482
+ isCollapsed,
483
+ toggleCollapsed,
484
+ displayContent,
485
+ shouldCollapse,
486
+ } = useCollapsibleContent(
487
+ trimmedContent,
488
+ collapsible ? collapsibleOptions : {}
489
+ );
490
+
491
+ // Call onCollapseChange when state changes
492
+ React.useEffect(() => {
493
+ if (collapsible && shouldCollapse && onCollapseChange) {
494
+ onCollapseChange(isCollapsed);
495
+ }
496
+ }, [isCollapsed, collapsible, shouldCollapse, onCollapseChange]);
497
+
342
498
  const components = React.useMemo(() => createMarkdownComponents(isUser, isCompact), [isUser, isCompact]);
343
499
 
344
500
  const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
345
501
  const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
346
502
 
347
503
  // For plain text without markdown, render directly without ReactMarkdown
348
- const isPlainText = !hasMarkdownSyntax(trimmedContent);
504
+ const isPlainText = !hasMarkdownSyntax(displayContent);
349
505
 
506
+ // Render plain text
350
507
  if (isPlainText) {
351
508
  return (
352
509
  <span className={`${textSizeClass} leading-relaxed break-words ${className}`}>
353
- {trimmedContent}
510
+ {displayContent}
511
+ {collapsible && shouldCollapse && (
512
+ <>
513
+ {isCollapsed && '... '}
514
+ <CollapseToggle
515
+ isCollapsed={isCollapsed}
516
+ onClick={toggleCollapsed}
517
+ readMoreLabel={readMoreLabel}
518
+ showLessLabel={showLessLabel}
519
+ isUser={isUser}
520
+ isCompact={isCompact}
521
+ />
522
+ </>
523
+ )}
354
524
  </span>
355
525
  );
356
526
  }
357
527
 
528
+ // Render markdown
358
529
  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}
530
+ <div className={className}>
531
+ <div
532
+ className={`
533
+ prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
534
+ ${isUser ? 'prose-invert' : 'dark:prose-invert'}
535
+ `}
536
+ style={{
537
+ // Inherit colors from parent - fixes issues with external CSS variables
538
+ '--tw-prose-body': 'inherit',
539
+ '--tw-prose-headings': 'inherit',
540
+ '--tw-prose-bold': 'inherit',
541
+ '--tw-prose-links': 'inherit',
542
+ color: 'inherit',
543
+ } as React.CSSProperties}
377
544
  >
378
- {trimmedContent}
379
- </ReactMarkdown>
545
+ <ReactMarkdown
546
+ remarkPlugins={[remarkGfm]}
547
+ components={components}
548
+ >
549
+ {displayContent}
550
+ </ReactMarkdown>
551
+ </div>
552
+ {collapsible && shouldCollapse && (
553
+ <CollapseToggle
554
+ isCollapsed={isCollapsed}
555
+ onClick={toggleCollapsed}
556
+ readMoreLabel={readMoreLabel}
557
+ showLessLabel={showLessLabel}
558
+ isUser={isUser}
559
+ isCompact={isCompact}
560
+ />
561
+ )}
380
562
  </div>
381
563
  );
382
564
  };
@@ -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;