@djangocfg/widget-code 0.1.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/package.json +119 -0
  4. package/src/diff-viewer/DiffViewer.tsx +168 -0
  5. package/src/diff-viewer/README.md +33 -0
  6. package/src/diff-viewer/components/CopyButton.tsx +49 -0
  7. package/src/diff-viewer/components/DiffLineContent.tsx +48 -0
  8. package/src/diff-viewer/components/SplitView.tsx +270 -0
  9. package/src/diff-viewer/components/UnifiedView.tsx +189 -0
  10. package/src/diff-viewer/diff-viewer.stories.tsx +168 -0
  11. package/src/diff-viewer/hooks/useDiff.ts +47 -0
  12. package/src/diff-viewer/hooks/useHighlighter.ts +54 -0
  13. package/src/diff-viewer/index.ts +22 -0
  14. package/src/diff-viewer/lazy.tsx +25 -0
  15. package/src/diff-viewer/types.ts +125 -0
  16. package/src/diff-viewer/utils/computeDiff.ts +171 -0
  17. package/src/editor/README.md +187 -0
  18. package/src/editor/components/DiffEditor.tsx +158 -0
  19. package/src/editor/components/Editor.tsx +293 -0
  20. package/src/editor/components/index.ts +2 -0
  21. package/src/editor/context/EditorProvider.tsx +212 -0
  22. package/src/editor/context/index.ts +1 -0
  23. package/src/editor/editor.stories.tsx +397 -0
  24. package/src/editor/hooks/index.ts +4 -0
  25. package/src/editor/hooks/useEditor.ts +36 -0
  26. package/src/editor/hooks/useEditorTheme.ts +91 -0
  27. package/src/editor/hooks/useLanguage.ts +29 -0
  28. package/src/editor/hooks/useMonaco.ts +79 -0
  29. package/src/editor/index.ts +16 -0
  30. package/src/editor/lazy.tsx +76 -0
  31. package/src/editor/lib/index.ts +2 -0
  32. package/src/editor/lib/languages.ts +227 -0
  33. package/src/editor/lib/themes.ts +42 -0
  34. package/src/editor/types/index.ts +137 -0
  35. package/src/editor/workers/index.ts +1 -0
  36. package/src/editor/workers/setup.ts +58 -0
  37. package/src/index.ts +10 -0
  38. package/src/json-editor/JsonEditor.tsx +115 -0
  39. package/src/json-editor/index.ts +1 -0
  40. package/src/json-editor/lazy.tsx +24 -0
  41. package/src/markdown-message/ActionRow.tsx +53 -0
  42. package/src/markdown-message/ChatMessageRow.tsx +103 -0
  43. package/src/markdown-message/CodeBlock.tsx +137 -0
  44. package/src/markdown-message/CollapseToggle.tsx +69 -0
  45. package/src/markdown-message/MarkdownMessage.tsx +376 -0
  46. package/src/markdown-message/README.md +221 -0
  47. package/src/markdown-message/builtinRules.tsx +92 -0
  48. package/src/markdown-message/components.tsx +224 -0
  49. package/src/markdown-message/index.ts +22 -0
  50. package/src/markdown-message/linkRules.ts +83 -0
  51. package/src/markdown-message/plainText.ts +83 -0
  52. package/src/markdown-message/sanitize.ts +88 -0
  53. package/src/markdown-message/streamingBlocks.ts +168 -0
  54. package/src/markdown-message/types.ts +168 -0
  55. package/src/panel-header/CodePanelHeader.tsx +121 -0
  56. package/src/panel-header/index.ts +1 -0
  57. package/src/pretty-code/PrettyCode.client.tsx +402 -0
  58. package/src/pretty-code/README.md +81 -0
  59. package/src/pretty-code/index.tsx +72 -0
  60. package/src/pretty-code/lazy.tsx +69 -0
  61. package/src/pretty-code/pretty-code.stories.tsx +353 -0
  62. package/src/pretty-code/registerPrismLanguages.ts +116 -0
  63. package/src/role-tokens.ts +39 -0
  64. package/src/use-collapsible-content.ts +236 -0
@@ -0,0 +1,24 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * `@djangocfg/widget-code/json-editor` subpath entrypoint.
5
+ *
6
+ * Heavy editor (~58 KB minified for `json-edit-react` alone). Lazy-load
7
+ * so consumers that only display JSON pay nothing — they should use
8
+ * `@djangocfg/widget-data/json-tree` instead, which is ~10× lighter.
9
+ */
10
+
11
+ import { createLazyComponent, LoadingFallback } from '@djangocfg/widget-kit/lazy';
12
+ import type { JsonEditorProps } from './JsonEditor';
13
+
14
+ export type { JsonEditorProps } from './JsonEditor';
15
+
16
+ export const LazyJsonEditor = createLazyComponent<JsonEditorProps>(
17
+ () => import('./JsonEditor').then((m) => ({ default: m.JsonEditor })),
18
+ {
19
+ displayName: 'LazyJsonEditor',
20
+ fallback: <LoadingFallback minHeight={120} text="Loading JSON editor…" />,
21
+ },
22
+ );
23
+
24
+ export { LazyJsonEditor as JsonEditor };
@@ -0,0 +1,53 @@
1
+ 'use client';
2
+
3
+ import React, { memo } from 'react';
4
+ import { CopyButton } from '@djangocfg/ui-core/components';
5
+
6
+ interface ActionRowProps {
7
+ /** Raw text to copy — pass the same source string used for render. */
8
+ value: string;
9
+ /** Bubble side. Just controls flex alignment; nothing is mirrored. */
10
+ isUser: boolean;
11
+ /**
12
+ * Whether the row is currently shown. Owner-controlled (typically
13
+ * `<ChatMessageRow>` toggles this on hover with a small close delay).
14
+ * The component is intentionally dumb about visibility — owners
15
+ * have full control over reveal logic (hover, focus, always-on,
16
+ * touch behaviour, etc.).
17
+ */
18
+ visible: boolean;
19
+ }
20
+
21
+ // Action row that lives directly under a chat bubble.
22
+ // Visibility is fully owner-controlled via `visible`.
23
+ //
24
+ // We render at `opacity-0` (not `display:none`) so the fade in/out
25
+ // stays smooth across re-renders. While invisible we drop pointer
26
+ // events so a hidden row can't intercept clicks meant for the page
27
+ // underneath.
28
+ //
29
+ // Memoised: re-renders only when `value`, `isUser` or `visible` change.
30
+ // All props are primitives, so default shallow comparison is sufficient.
31
+ function ActionRowRaw({ value, isUser, visible }: ActionRowProps) {
32
+ return (
33
+ <div
34
+ className={`flex ${isUser ? 'justify-end' : 'justify-start'}
35
+ transition-opacity duration-150
36
+ ${visible ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}`}
37
+ >
38
+ <CopyButton
39
+ value={value}
40
+ size="icon"
41
+ variant="ghost"
42
+ iconClassName="h-3.5 w-3.5"
43
+ className="h-7 w-7 text-muted-foreground hover:text-foreground"
44
+ // Hidden rows must not be reachable via Tab — otherwise
45
+ // keyboard users land on an invisible button.
46
+ tabIndex={visible ? 0 : -1}
47
+ aria-hidden={!visible}
48
+ />
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export const ActionRow = memo(ActionRowRaw);
@@ -0,0 +1,103 @@
1
+ 'use client';
2
+
3
+ import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
4
+ import { useMediaQuery } from '@djangocfg/ui-core/hooks';
5
+
6
+ interface ChatMessageRowProps {
7
+ /** Side of the conversation — drives flex alignment and `isUser`
8
+ * passed to `actions`. */
9
+ isUser: boolean;
10
+ /** The bubble (your own JSX). Anything goes — `<MarkdownMessage>`,
11
+ * custom card, multi-element composition. */
12
+ children: React.ReactNode;
13
+ /** Render-prop for the action row. Receives `visible` so the row
14
+ * knows when to fade in/out. Render `null` to opt out. */
15
+ actions?: (visible: boolean, isUser: boolean) => React.ReactNode;
16
+ /** Close delay in ms after the cursor leaves the row. Long enough
17
+ * to bridge cursor travel from bubble → action button without
18
+ * flicker. Default 250ms (Radix Tooltip-ish). */
19
+ closeDelayMs?: number;
20
+ /** Optional class on the column container. */
21
+ className?: string;
22
+ }
23
+
24
+ // Owner of the bubble + action row layout.
25
+ //
26
+ // Why a state-based hover instead of `group-hover` CSS:
27
+ // - CSS-only hover bridges break when the cursor crosses gaps
28
+ // between the bubble and the (initially-invisible) row — the
29
+ // row's `pointer-events-none` lets the cursor "fall through",
30
+ // `group-hover` flips off, the row vanishes mid-travel.
31
+ // - State + a small close timeout gives Radix-Tooltip-style
32
+ // stability: enter shows immediately, leave waits N ms.
33
+ // - On touch (`@media (hover:none)`) hover doesn't exist; we
34
+ // always pass `visible={true}` so the affordance is reachable.
35
+ //
36
+ // The action row is rendered **absolutely** under the bubble so
37
+ // hidden rows don't allocate vertical space — no dead-air gap
38
+ // between consecutive messages.
39
+ //
40
+ // Memoised: re-renders only when `isUser`, `children`, `actions`,
41
+ // `closeDelayMs` or `className` change. `actions` render-prop is
42
+ // compared by reference — callers should stabilise it with useCallback.
43
+ function ChatMessageRowRaw({
44
+ isUser,
45
+ children,
46
+ actions,
47
+ closeDelayMs = 250,
48
+ className = '',
49
+ }: ChatMessageRowProps) {
50
+ const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
51
+ const [hovered, setHovered] = useState(false);
52
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
53
+
54
+ const cancelClose = useCallback(() => {
55
+ if (closeTimer.current) {
56
+ clearTimeout(closeTimer.current);
57
+ closeTimer.current = null;
58
+ }
59
+ }, []);
60
+
61
+ const open = useCallback(() => {
62
+ cancelClose();
63
+ setHovered(true);
64
+ }, [cancelClose]);
65
+
66
+ const scheduleClose = useCallback(() => {
67
+ cancelClose();
68
+ closeTimer.current = setTimeout(() => setHovered(false), closeDelayMs);
69
+ }, [cancelClose, closeDelayMs]);
70
+
71
+ useEffect(() => () => cancelClose(), [cancelClose]);
72
+
73
+ // Touch: always-visible. Hover: state-driven.
74
+ const visible = isTouch || hovered;
75
+
76
+ // On touch, `static` positioning so the row claims real layout
77
+ // space (otherwise an absolute always-visible row would still
78
+ // overlap the next message). On hover devices, `absolute` so the
79
+ // hidden row doesn't leave dead air between bubbles.
80
+ const rowPositionClass = isTouch ? 'static mt-1' : 'absolute top-full';
81
+ const rowSideClass = isUser ? 'right-0' : 'left-0';
82
+
83
+ return (
84
+ <div
85
+ className={`relative flex flex-col ${isUser ? 'items-end' : 'items-start'} ${className}`}
86
+ onPointerEnter={isTouch ? undefined : open}
87
+ onPointerLeave={isTouch ? undefined : scheduleClose}
88
+ onFocusCapture={open}
89
+ onBlurCapture={(e) => {
90
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) scheduleClose();
91
+ }}
92
+ >
93
+ {children}
94
+ {actions && (
95
+ <div className={`${rowPositionClass} ${rowSideClass}`}>
96
+ {actions(visible, isUser)}
97
+ </div>
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export const ChatMessageRow = memo(ChatMessageRowRaw);
@@ -0,0 +1,137 @@
1
+ import React, { memo } from 'react';
2
+ import { CopyButton } from '@djangocfg/ui-core/components';
3
+ import PrettyCode from '../pretty-code';
4
+
5
+ interface CodeBlockProps {
6
+ code: string;
7
+ language: string;
8
+ isUser: boolean;
9
+ isCompact?: boolean;
10
+ /**
11
+ * Code surface palette. Default `'auto'` — follow the host theme.
12
+ * `'dark'` / `'light'` pin the palette regardless of theme.
13
+ */
14
+ codeTheme?: 'dark' | 'light' | 'auto';
15
+ }
16
+
17
+ /**
18
+ * Markdown code block. Renders <PrettyCode> directly — its own
19
+ * FloatingToolbar already carries the language tag and Copy action.
20
+ *
21
+ * Earlier versions stacked an extra <CopyButton> here too, which
22
+ * surfaced as two copy buttons on every code fence. The fallback
23
+ * branch below still ships its own button because the plain <pre>
24
+ * has no toolbar of its own.
25
+ *
26
+ * Memoised: re-renders only when `code`, `language`, `isUser` or
27
+ * `isCompact` change. `code` is the main trigger — long code blocks
28
+ * should not re-render when parent chat scrolls.
29
+ */
30
+ function CodeBlockRaw({ code, language, codeTheme = 'auto' }: CodeBlockProps) {
31
+ // Chat fences are always rendered in PrettyCode's compact mode:
32
+ // 12px font, tighter padding, line-height 1.4. A fenced block in
33
+ // a chat bubble shouldn't outweigh two paragraphs of body text —
34
+ // the standalone PrettyCode story keeps the larger default for
35
+ // docs/diff viewers.
36
+ //
37
+ // `mode` must be UNDEFINED to follow the theme: PrettyCode resolves it as
38
+ // `mode ?? resolvedTheme`, so passing a concrete value — as this did with
39
+ // its old `'dark'` default — pins the palette and defeats that fallback.
40
+ // The pin also propagated to `CodePanelHeader` (`dark={isDarkMode}`), which
41
+ // is why the language label and copy icon stayed `text-white/40` on a light
42
+ // panel. `'auto'` is the default now; a host can still pin deliberately.
43
+ const pinnedMode = codeTheme === 'auto' ? undefined : codeTheme;
44
+ return (
45
+ <div className="my-2">
46
+ <PrettyCode
47
+ data={code}
48
+ language={language}
49
+ className="text-xs"
50
+ mode={pinnedMode}
51
+ // No `customBg` — let PrettyCode paint the surface via its inline
52
+ // `var(--code)` style (see surfaceStyle there). We used to pass
53
+ // `customBg="bg-code"`, but that Tailwind utility only exists in the
54
+ // generated CSS if the CONSUMER scans ui-core/widget source in its
55
+ // Tailwind `content` globs — and most (e.g. cmdop) do not. The class
56
+ // then landed in the DOM with no rule behind it, so the panel rendered
57
+ // with a transparent surface in light mode (in dark mode the ambient
58
+ // dark page hid it). The inline var()-based surface needs no content
59
+ // scan, so it renders correctly in every consumer and both themes.
60
+ isCompact
61
+ // Disable click-to-scroll-isolation in chat markdown: code
62
+ // fences here are part of an assistant reply, not a docs
63
+ // viewer. Forcing the user to click into a small block to
64
+ // scroll past it interrupts the reading flow. The standalone
65
+ // PrettyCode use cases (docs, diff viewers, long code panes)
66
+ // keep the default `true`.
67
+ scrollIsolation={false}
68
+ />
69
+ </div>
70
+ );
71
+ }
72
+
73
+ export const CodeBlock = memo(CodeBlockRaw);
74
+
75
+ /** Simple `<pre>` fallback used when CodeBlock throws (lazy module
76
+ * failure, missing PrettyCode peer, etc).
77
+ *
78
+ * Memoised: re-renders only when `code`, `language`, `isUser` or
79
+ * `isCompact` change.
80
+ */
81
+ function CodeBlockFallbackRaw({ code, codeTheme = 'auto' }: CodeBlockProps) {
82
+ // Only an explicit `'dark'` pin takes the hard-coded dark surface. `'auto'`
83
+ // uses the token branch, which is already theme-aware (`--code*` is defined
84
+ // in both light.css and dark.css) — so it renders correctly in EITHER theme.
85
+ // Testing `=== 'dark'` and letting everything else fall through to a fixed
86
+ // LIGHT surface would have been the same class of bug this fixes, inverted.
87
+ const isDark = codeTheme === 'dark';
88
+ // On the dark surface the copy button is always light; on a light
89
+ // surface it follows the semantic muted token.
90
+ const copyButtonClass =
91
+ `absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8 ` +
92
+ (isDark
93
+ ? 'text-white/70 hover:text-white hover:bg-white/15'
94
+ : 'text-muted-foreground hover:text-foreground hover:bg-muted');
95
+
96
+ return (
97
+ <div className="relative group my-3">
98
+ <CopyButton
99
+ value={code}
100
+ variant="ghost"
101
+ className={copyButtonClass}
102
+ title="Copy code"
103
+ />
104
+ {isDark ? (
105
+ // Fixed dark surface to match PrettyCode's default.
106
+ <pre
107
+ className="p-3 rounded text-xs font-mono overflow-x-auto border"
108
+ style={{
109
+ backgroundColor: '#0d1117',
110
+ color: '#e5e7eb',
111
+ borderColor: '#1f2937',
112
+ }}
113
+ >
114
+ <code>{code}</code>
115
+ </pre>
116
+ ) : (
117
+ <pre
118
+ className="p-3 rounded text-xs font-mono overflow-x-auto border"
119
+ // Inline `var(--code*)` rather than `bg-code`/`text-code-foreground`/
120
+ // `border-code-border` classes: those utilities only exist when the
121
+ // consumer scans this widget's source in its Tailwind content globs, which
122
+ // many don't — see the note in CodeBlockRaw. The vars are defined in
123
+ // both light.css and dark.css, so this stays theme-aware everywhere.
124
+ style={{
125
+ backgroundColor: 'var(--code)',
126
+ color: 'var(--code-foreground)',
127
+ borderColor: 'var(--code-border)',
128
+ }}
129
+ >
130
+ <code>{code}</code>
131
+ </pre>
132
+ )}
133
+ </div>
134
+ );
135
+ }
136
+
137
+ export const CodeBlockFallback = memo(CodeBlockFallbackRaw);
@@ -0,0 +1,69 @@
1
+ import React, { memo } from 'react';
2
+
3
+ import { TOGGLE } from '../role-tokens';
4
+
5
+ interface CollapseToggleProps {
6
+ isCollapsed: boolean;
7
+ onClick: () => void;
8
+ readMoreLabel: string;
9
+ showLessLabel: string;
10
+ isUser: boolean;
11
+ isCompact: boolean;
12
+ }
13
+
14
+ /** "Read more..." / "Show less" button.
15
+ *
16
+ * Memoised: re-renders only when `isCollapsed`, `onClick`, `readMoreLabel`,
17
+ * `showLessLabel`, `isUser` or `isCompact` change. `onClick` is compared
18
+ * by reference — callers should stabilise it with useCallback.
19
+ */
20
+ function CollapseToggleRaw({
21
+ isCollapsed,
22
+ onClick,
23
+ readMoreLabel,
24
+ showLessLabel,
25
+ isUser,
26
+ isCompact,
27
+ }: CollapseToggleProps) {
28
+ const textSize = isCompact ? 'text-xs' : 'text-sm';
29
+ return (
30
+ <button
31
+ type="button"
32
+ onClick={onClick}
33
+ className={`
34
+ ${textSize} font-medium cursor-pointer
35
+ transition-colors duration-200
36
+ ${isUser ? TOGGLE.user : TOGGLE.assistant}
37
+ inline-flex items-center gap-1
38
+ mt-1
39
+ `}
40
+ >
41
+ {isCollapsed ? (
42
+ <>
43
+ {readMoreLabel}
44
+ <Chevron direction="down" />
45
+ </>
46
+ ) : (
47
+ <>
48
+ {showLessLabel}
49
+ <Chevron direction="up" />
50
+ </>
51
+ )}
52
+ </button>
53
+ );
54
+ };
55
+
56
+ const Chevron = memo(function Chevron({ direction }: { direction: 'up' | 'down' }) {
57
+ return (
58
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
59
+ <path
60
+ strokeLinecap="round"
61
+ strokeLinejoin="round"
62
+ strokeWidth={2}
63
+ d={direction === 'down' ? 'M19 9l-7 7-7-7' : 'M5 15l7-7 7 7'}
64
+ />
65
+ </svg>
66
+ );
67
+ });
68
+
69
+ export const CollapseToggle = memo(CollapseToggleRaw);