@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.92",
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.92",
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.92",
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(content);
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
- {content}
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
- className={`
353
- prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
354
- ${isUser ? 'prose-invert' : 'dark:prose-invert'}
355
- ${className}
356
- `}
357
- style={{
358
- // Inherit colors from parent - fixes issues with external CSS variables
359
- '--tw-prose-body': 'inherit',
360
- '--tw-prose-headings': 'inherit',
361
- '--tw-prose-bold': 'inherit',
362
- '--tw-prose-links': 'inherit',
363
- color: 'inherit',
364
- } as React.CSSProperties}
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
- {content}
371
- </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
+ )}
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;