@workerdeck/ui 0.6.0
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/LICENSE +21 -0
- package/README.md +74 -0
- package/build/index.d.mts +927 -0
- package/build/index.mjs +5236 -0
- package/build/index.mjs.map +1 -0
- package/package.json +73 -0
- package/src/components/agent/Composer.tsx +139 -0
- package/src/components/agent/Conversation.tsx +59 -0
- package/src/components/agent/FileCard.tsx +50 -0
- package/src/components/agent/Loader.tsx +21 -0
- package/src/components/agent/Message.tsx +44 -0
- package/src/components/agent/ModelSelect.tsx +87 -0
- package/src/components/agent/PermissionModeSelect.tsx +94 -0
- package/src/components/agent/PermissionPrompt.tsx +52 -0
- package/src/components/agent/QuestionPrompt.tsx +193 -0
- package/src/components/agent/Reasoning.tsx +58 -0
- package/src/components/agent/Response.tsx +31 -0
- package/src/components/agent/SessionList.tsx +93 -0
- package/src/components/agent/SessionPanel.tsx +149 -0
- package/src/components/agent/StatusBar.tsx +140 -0
- package/src/components/agent/ToolCallCard.tsx +94 -0
- package/src/components/agent/Transcript.tsx +114 -0
- package/src/components/agent/status.ts +16 -0
- package/src/components/prompt-area/animated-placeholder.tsx +42 -0
- package/src/components/prompt-area/clipboard-helpers.ts +206 -0
- package/src/components/prompt-area/cursor-helpers.ts +244 -0
- package/src/components/prompt-area/dom-helpers.ts +721 -0
- package/src/components/prompt-area/file-strip.tsx +250 -0
- package/src/components/prompt-area/html-to-markdown.ts +278 -0
- package/src/components/prompt-area/image-strip.tsx +49 -0
- package/src/components/prompt-area/index.ts +23 -0
- package/src/components/prompt-area/prompt-area-engine.ts +705 -0
- package/src/components/prompt-area/prompt-area-list-ops.ts +499 -0
- package/src/components/prompt-area/prompt-area.tsx +375 -0
- package/src/components/prompt-area/remove-button.tsx +37 -0
- package/src/components/prompt-area/segment-helpers.ts +62 -0
- package/src/components/prompt-area/trigger-popover.tsx +139 -0
- package/src/components/prompt-area/trigger-presets.ts +143 -0
- package/src/components/prompt-area/types.ts +360 -0
- package/src/components/prompt-area/use-markdown-mode.ts +113 -0
- package/src/components/prompt-area/use-prompt-area-events.ts +470 -0
- package/src/components/prompt-area/use-prompt-area-state.ts +131 -0
- package/src/components/prompt-area/use-prompt-area.ts +1507 -0
- package/src/components/prompt-area/use-trigger-search.ts +115 -0
- package/src/components/ui/AlertDialog.tsx +56 -0
- package/src/components/ui/Badge.tsx +42 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Card.tsx +29 -0
- package/src/components/ui/CodeBlock.tsx +31 -0
- package/src/components/ui/CopyButton.tsx +28 -0
- package/src/components/ui/Input.tsx +20 -0
- package/src/components/ui/ProgressRing.tsx +49 -0
- package/src/components/ui/Select.tsx +80 -0
- package/src/components/ui/Sonner.tsx +22 -0
- package/src/components/ui/Spinner.tsx +6 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/Tooltip.tsx +34 -0
- package/src/index.ts +99 -0
- package/src/lib/format.ts +67 -0
- package/src/lib/utils.ts +33 -0
- package/src/styles/theme.css +413 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils.ts'
|
|
5
|
+
import { RemoveButton } from './remove-button.tsx'
|
|
6
|
+
import type { PromptAreaFile } from './types.ts'
|
|
7
|
+
|
|
8
|
+
type IconProps = { className?: string }
|
|
9
|
+
|
|
10
|
+
/** Shared SVG wrapper matching the lucide icon defaults (no dependency). */
|
|
11
|
+
function Svg({ className, children }: IconProps & { children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<svg
|
|
14
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
15
|
+
width="24"
|
|
16
|
+
height="24"
|
|
17
|
+
viewBox="0 0 24 24"
|
|
18
|
+
fill="none"
|
|
19
|
+
stroke="currentColor"
|
|
20
|
+
strokeWidth="2"
|
|
21
|
+
strokeLinecap="round"
|
|
22
|
+
strokeLinejoin="round"
|
|
23
|
+
aria-hidden="true"
|
|
24
|
+
className={className}>
|
|
25
|
+
{children}
|
|
26
|
+
</svg>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FileBody = (
|
|
31
|
+
<>
|
|
32
|
+
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
|
33
|
+
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
|
34
|
+
</>
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const File = ({ className }: IconProps) => <Svg className={className}>{FileBody}</Svg>
|
|
38
|
+
const FileText = ({ className }: IconProps) => (
|
|
39
|
+
<Svg className={className}>
|
|
40
|
+
{FileBody}
|
|
41
|
+
<path d="M10 9H8" />
|
|
42
|
+
<path d="M16 13H8" />
|
|
43
|
+
<path d="M16 17H8" />
|
|
44
|
+
</Svg>
|
|
45
|
+
)
|
|
46
|
+
const FileSpreadsheet = ({ className }: IconProps) => (
|
|
47
|
+
<Svg className={className}>
|
|
48
|
+
{FileBody}
|
|
49
|
+
<path d="M8 13h2" />
|
|
50
|
+
<path d="M14 13h2" />
|
|
51
|
+
<path d="M8 17h2" />
|
|
52
|
+
<path d="M14 17h2" />
|
|
53
|
+
</Svg>
|
|
54
|
+
)
|
|
55
|
+
const FileCode = ({ className }: IconProps) => (
|
|
56
|
+
<Svg className={className}>
|
|
57
|
+
{FileBody}
|
|
58
|
+
<path d="M10 12.5 8 15l2 2.5" />
|
|
59
|
+
<path d="m14 12.5 2 2.5-2 2.5" />
|
|
60
|
+
</Svg>
|
|
61
|
+
)
|
|
62
|
+
const ImageIcon = ({ className }: IconProps) => (
|
|
63
|
+
<Svg className={className}>
|
|
64
|
+
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
65
|
+
<circle cx="9" cy="9" r="2" />
|
|
66
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
67
|
+
</Svg>
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
type FileStripProps = {
|
|
71
|
+
files: PromptAreaFile[]
|
|
72
|
+
onRemove?: (file: PromptAreaFile) => void
|
|
73
|
+
onClick?: (file: PromptAreaFile) => void
|
|
74
|
+
className?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Threshold above which collapse activates automatically. */
|
|
78
|
+
const COLLAPSE_THRESHOLD = 3
|
|
79
|
+
|
|
80
|
+
/** Pick a lucide icon key based on MIME type. */
|
|
81
|
+
function getFileIconKey(type?: string): 'pdf' | 'spreadsheet' | 'code' | 'image' | 'default' {
|
|
82
|
+
if (!type) return 'default'
|
|
83
|
+
if (type === 'application/pdf') return 'pdf'
|
|
84
|
+
if (type.includes('spreadsheet') || type === 'text/csv') return 'spreadsheet'
|
|
85
|
+
if (
|
|
86
|
+
type.startsWith('text/') ||
|
|
87
|
+
type.includes('javascript') ||
|
|
88
|
+
type.includes('json') ||
|
|
89
|
+
type.includes('xml')
|
|
90
|
+
)
|
|
91
|
+
return 'code'
|
|
92
|
+
if (type.startsWith('image/')) return 'image'
|
|
93
|
+
return 'default'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const FILE_ICONS = {
|
|
97
|
+
pdf: FileText,
|
|
98
|
+
spreadsheet: FileSpreadsheet,
|
|
99
|
+
code: FileCode,
|
|
100
|
+
image: ImageIcon,
|
|
101
|
+
default: File,
|
|
102
|
+
} as const
|
|
103
|
+
|
|
104
|
+
/** Format bytes into a human-readable string. */
|
|
105
|
+
function formatFileSize(bytes: number): string {
|
|
106
|
+
if (bytes < 1024) return `${bytes} B`
|
|
107
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
108
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
109
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Extract a short extension label from a filename (e.g., "PDF", "CSV"). */
|
|
113
|
+
function getExtensionLabel(name: string): string | null {
|
|
114
|
+
const dot = name.lastIndexOf('.')
|
|
115
|
+
if (dot === -1 || dot === name.length - 1) return null
|
|
116
|
+
return name.slice(dot + 1).toUpperCase()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function FileCard({
|
|
120
|
+
file,
|
|
121
|
+
compact,
|
|
122
|
+
onRemove,
|
|
123
|
+
onClick,
|
|
124
|
+
}: {
|
|
125
|
+
file: PromptAreaFile
|
|
126
|
+
compact: boolean
|
|
127
|
+
onRemove?: (file: PromptAreaFile) => void
|
|
128
|
+
onClick?: (file: PromptAreaFile) => void
|
|
129
|
+
}) {
|
|
130
|
+
const ext = getExtensionLabel(file.name)
|
|
131
|
+
const sizeStr = file.size != null ? formatFileSize(file.size) : null
|
|
132
|
+
const meta = [ext, sizeStr].filter(Boolean).join(' · ')
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
role="listitem"
|
|
137
|
+
className={cn(
|
|
138
|
+
'border-border relative flex flex-shrink-0 items-center gap-2 overflow-hidden rounded-lg border transition-colors',
|
|
139
|
+
'hover:bg-surface-hover',
|
|
140
|
+
compact ? 'h-10 w-36 px-2' : 'h-14 w-48 px-3',
|
|
141
|
+
onClick && 'cursor-pointer',
|
|
142
|
+
)}
|
|
143
|
+
onClick={() => onClick?.(file)}>
|
|
144
|
+
{(() => {
|
|
145
|
+
const Icon = FILE_ICONS[getFileIconKey(file.type)]
|
|
146
|
+
return (
|
|
147
|
+
<Icon
|
|
148
|
+
className={cn('text-muted-foreground flex-shrink-0', compact ? 'h-4 w-4' : 'h-5 w-5')}
|
|
149
|
+
/>
|
|
150
|
+
)
|
|
151
|
+
})()}
|
|
152
|
+
<div className="min-w-0 flex-1">
|
|
153
|
+
<div
|
|
154
|
+
className={cn('truncate font-medium', compact ? 'text-xs' : 'text-sm')}
|
|
155
|
+
title={file.name}>
|
|
156
|
+
{file.name}
|
|
157
|
+
</div>
|
|
158
|
+
{!compact && meta && <div className="text-muted-foreground truncate text-xs">{meta}</div>}
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
{file.loading && (
|
|
162
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
163
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{onRemove && <RemoveButton onClick={() => onRemove(file)} label={`Remove ${file.name}`} />}
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function FileStrip({ files, onRemove, onClick, className }: FileStripProps) {
|
|
173
|
+
const [expanded, setExpanded] = useState(false)
|
|
174
|
+
const popoverRef = useRef<HTMLDivElement>(null)
|
|
175
|
+
const toggleRef = useRef<HTMLButtonElement>(null)
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
if (!expanded) return
|
|
179
|
+
const handleClick = (e: MouseEvent) => {
|
|
180
|
+
const target = e.target
|
|
181
|
+
if (!(target instanceof Node)) return
|
|
182
|
+
if (
|
|
183
|
+
popoverRef.current &&
|
|
184
|
+
!popoverRef.current.contains(target) &&
|
|
185
|
+
!toggleRef.current?.contains(target)
|
|
186
|
+
) {
|
|
187
|
+
setExpanded(false)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
document.addEventListener('mousedown', handleClick)
|
|
191
|
+
return () => document.removeEventListener('mousedown', handleClick)
|
|
192
|
+
}, [expanded])
|
|
193
|
+
|
|
194
|
+
if (files.length === 0) return null
|
|
195
|
+
|
|
196
|
+
const collapsible = files.length > COLLAPSE_THRESHOLD
|
|
197
|
+
const compact = collapsible
|
|
198
|
+
const hiddenCount = files.length - COLLAPSE_THRESHOLD
|
|
199
|
+
const visibleFiles = files.slice(0, COLLAPSE_THRESHOLD)
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<div className={cn('relative', className)}>
|
|
203
|
+
<div className="flex flex-wrap gap-2" role="list" aria-label="Attached files">
|
|
204
|
+
{(collapsible ? visibleFiles : files).map((file) => (
|
|
205
|
+
<FileCard
|
|
206
|
+
key={file.id}
|
|
207
|
+
file={file}
|
|
208
|
+
compact={compact}
|
|
209
|
+
onRemove={onRemove}
|
|
210
|
+
onClick={onClick}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
213
|
+
{collapsible && (
|
|
214
|
+
<div role="listitem">
|
|
215
|
+
<button
|
|
216
|
+
ref={toggleRef}
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={() => setExpanded((v) => !v)}
|
|
219
|
+
className={cn(
|
|
220
|
+
'border-border text-muted-foreground hover:bg-surface-hover flex flex-shrink-0 cursor-pointer items-center justify-center rounded-lg border transition-colors',
|
|
221
|
+
compact ? 'h-10 px-3 text-xs' : 'h-14 px-4 text-sm',
|
|
222
|
+
)}>
|
|
223
|
+
{expanded ? 'Show less' : `+${hiddenCount} more`}
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
{expanded && (
|
|
230
|
+
<div
|
|
231
|
+
ref={popoverRef}
|
|
232
|
+
className={cn(
|
|
233
|
+
'bg-surface border-border absolute bottom-full left-0 z-10 mb-2 max-h-48 overflow-y-auto rounded-lg border p-2 shadow-lg',
|
|
234
|
+
)}>
|
|
235
|
+
<div className="flex flex-wrap gap-2" role="list" aria-label="More attached files">
|
|
236
|
+
{files.slice(COLLAPSE_THRESHOLD).map((file) => (
|
|
237
|
+
<FileCard
|
|
238
|
+
key={file.id}
|
|
239
|
+
file={file}
|
|
240
|
+
compact={compact}
|
|
241
|
+
onRemove={onRemove}
|
|
242
|
+
onClick={onClick}
|
|
243
|
+
/>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand-rolled, dependency-free HTML -> Markdown converter.
|
|
3
|
+
*
|
|
4
|
+
* Used by the paste handler: when the editor is in markdown mode and the
|
|
5
|
+
* clipboard carries rich `text/html` (web pages, Notion, Google Docs, GitHub,
|
|
6
|
+
* Slack, etc.), we convert it to markdown SOURCE text so the paste keeps its
|
|
7
|
+
* formatting. The resulting string flows through the same insertion path as a
|
|
8
|
+
* plain-text paste, and the editor's inline decorators render `*`/`**`/`***`
|
|
9
|
+
* and bare URLs automatically.
|
|
10
|
+
*
|
|
11
|
+
* Design constraints (see .size-limit.json): no runtime deps. Parsing uses the
|
|
12
|
+
* ambient `DOMParser`, walking is a small recursive switch. Type-safe: no
|
|
13
|
+
* `any`, DOM narrowed via the guards in `dom-helpers.ts`.
|
|
14
|
+
*/
|
|
15
|
+
import { isHTMLElement, isTextNode } from './dom-helpers.ts'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Inline style / emphasis detection
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** Reads a single declaration value from an inline `style` attribute string. */
|
|
22
|
+
function getStyleValue(style: string, prop: string): string {
|
|
23
|
+
const match = new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, 'i').exec(style)
|
|
24
|
+
return match ? match[1].trim().toLowerCase() : ''
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether a CSS font-weight value is bold (`bold`, `bolder`, or >= 600). */
|
|
28
|
+
function isBoldWeight(value: string): boolean {
|
|
29
|
+
if (value === 'bold' || value === 'bolder') return true
|
|
30
|
+
const numeric = Number.parseInt(value, 10)
|
|
31
|
+
return !Number.isNaN(numeric) && numeric >= 600
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Computes the markdown emphasis markers for an element from BOTH its tag and
|
|
36
|
+
* its inline style. Google Docs emits `<span style="font-weight:700">` rather
|
|
37
|
+
* than `<b>`, and wraps everything in `<b style="font-weight:normal">`, so an
|
|
38
|
+
* explicit `font-weight`/`font-style` always wins over the tag name.
|
|
39
|
+
*/
|
|
40
|
+
function inlineEmphasis(node: HTMLElement): { prefix: string; suffix: string } {
|
|
41
|
+
const tag = node.tagName
|
|
42
|
+
const style = node.getAttribute('style') ?? ''
|
|
43
|
+
|
|
44
|
+
const weight = getStyleValue(style, 'font-weight')
|
|
45
|
+
const bold = weight ? isBoldWeight(weight) : tag === 'B' || tag === 'STRONG'
|
|
46
|
+
|
|
47
|
+
const fontStyle = getStyleValue(style, 'font-style')
|
|
48
|
+
const italic = fontStyle
|
|
49
|
+
? fontStyle === 'italic' || fontStyle === 'oblique'
|
|
50
|
+
: tag === 'I' || tag === 'EM'
|
|
51
|
+
|
|
52
|
+
const decoration = getStyleValue(style, 'text-decoration')
|
|
53
|
+
const strike =
|
|
54
|
+
tag === 'S' || tag === 'DEL' || tag === 'STRIKE' || decoration.includes('line-through')
|
|
55
|
+
|
|
56
|
+
const prefix = (strike ? '~~' : '') + (bold ? '**' : '') + (italic ? '*' : '')
|
|
57
|
+
const suffix = (italic ? '*' : '') + (bold ? '**' : '') + (strike ? '~~' : '')
|
|
58
|
+
return { prefix, suffix }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Text handling
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** Collapses HTML whitespace runs (incl. ` ` -> U+00A0) to single spaces. */
|
|
66
|
+
function collapseWhitespace(text: string): string {
|
|
67
|
+
return text.replace(/\s+/g, ' ')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Escapes literal `*` from HTML text so prose isn't re-read as emphasis. */
|
|
71
|
+
function escapeText(text: string): string {
|
|
72
|
+
return text.replace(/\*/g, '\\*')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Block serializers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/** Derives a fenced-block language from `class="language-ts"` or `lang="ts"`. */
|
|
80
|
+
function detectCodeLang(pre: HTMLElement): string {
|
|
81
|
+
const code = pre.querySelector('code')
|
|
82
|
+
const classNames = `${pre.className} ${code?.className ?? ''}`
|
|
83
|
+
const fromClass = /(?:language|lang)-([\w-]+)/.exec(classNames)
|
|
84
|
+
if (fromClass) return fromClass[1]
|
|
85
|
+
return pre.getAttribute('lang') ?? code?.getAttribute('lang') ?? ''
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function serializePre(pre: HTMLElement): string {
|
|
89
|
+
const lang = detectCodeLang(pre)
|
|
90
|
+
const raw = (pre.textContent ?? '').replace(/\n$/, '')
|
|
91
|
+
return `\n\n\`\`\`${lang}\n${raw}\n\`\`\`\n\n`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function serializeInlineCode(node: HTMLElement): string {
|
|
95
|
+
const content = node.textContent ?? ''
|
|
96
|
+
if (content.includes('`')) return `\`\` ${content} \`\``
|
|
97
|
+
return `\`${content}\``
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** http(s)/mailto only; drops `#`, empty, and `javascript:` hrefs. */
|
|
101
|
+
function isSafeHref(href: string): boolean {
|
|
102
|
+
return /^(https?:|mailto:)/i.test(href)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serializeAnchor(node: HTMLElement, depth: number): string {
|
|
106
|
+
const href = node.getAttribute('href') ?? ''
|
|
107
|
+
const label = serializeChildren(node, depth).trim()
|
|
108
|
+
if (!isSafeHref(href)) return label
|
|
109
|
+
if (!label || label === href) return href
|
|
110
|
+
return `[${label}](${href})`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function serializeImage(node: HTMLElement): string {
|
|
114
|
+
const src = node.getAttribute('src') ?? ''
|
|
115
|
+
// Gate the src through the same allow-list as anchors: only http(s)/mailto
|
|
116
|
+
// survive, so a `javascript:`/`vbscript:`/`data:` src never reaches the
|
|
117
|
+
// emitted markdown (defense-in-depth for consumers that render it as HTML).
|
|
118
|
+
if (!src || !isSafeHref(src)) return ''
|
|
119
|
+
return ``
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function serializeBlockquote(node: HTMLElement, depth: number): string {
|
|
123
|
+
const inner = serializeChildren(node, depth).trim()
|
|
124
|
+
const quoted = inner
|
|
125
|
+
.split('\n')
|
|
126
|
+
.map((line) => (line ? `> ${line}` : '>'))
|
|
127
|
+
.join('\n')
|
|
128
|
+
return `\n\n${quoted}\n\n`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Serializes a `<ul>`/`<ol>` at nesting `depth` (0 = top level). Each `<li>`'s
|
|
133
|
+
* own inline content becomes the marker line; a nested `<ul>`/`<ol>` child is
|
|
134
|
+
* serialized at `depth + 1` and appended indented below its parent item.
|
|
135
|
+
*/
|
|
136
|
+
function serializeList(list: HTMLElement, depth: number): string {
|
|
137
|
+
const ordered = list.tagName === 'OL'
|
|
138
|
+
const start = Number.parseInt(list.getAttribute('start') ?? '', 10)
|
|
139
|
+
let index = Number.isNaN(start) ? 1 : start
|
|
140
|
+
const indent = ' '.repeat(depth)
|
|
141
|
+
const lines: string[] = []
|
|
142
|
+
|
|
143
|
+
for (const child of Array.from(list.childNodes)) {
|
|
144
|
+
if (!isHTMLElement(child) || child.tagName !== 'LI') continue
|
|
145
|
+
|
|
146
|
+
const marker = ordered ? `${index}. ` : '- '
|
|
147
|
+
index++
|
|
148
|
+
|
|
149
|
+
let label = ''
|
|
150
|
+
let nested = ''
|
|
151
|
+
for (const liChild of Array.from(child.childNodes)) {
|
|
152
|
+
if (isHTMLElement(liChild) && (liChild.tagName === 'UL' || liChild.tagName === 'OL')) {
|
|
153
|
+
nested += `\n${serializeList(liChild, depth + 1)}`
|
|
154
|
+
} else {
|
|
155
|
+
label += serializeNode(liChild, depth)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push(`${indent}${marker}${label.trim()}${nested}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return lines.join('\n')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function serializeTable(table: HTMLElement, depth: number): string {
|
|
165
|
+
const rows = Array.from(table.querySelectorAll('tr'))
|
|
166
|
+
if (rows.length === 0) return ''
|
|
167
|
+
|
|
168
|
+
const cells = rows.map((row) =>
|
|
169
|
+
Array.from(row.children)
|
|
170
|
+
.filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
|
|
171
|
+
.map((cell) =>
|
|
172
|
+
serializeChildren(cell, depth).replace(/\n+/g, ' ').replace(/\|/g, '\\|').trim(),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const header = cells[0]
|
|
177
|
+
const separator = header.map(() => '---')
|
|
178
|
+
const toRow = (row: string[]): string => `| ${row.join(' | ')} |`
|
|
179
|
+
|
|
180
|
+
return [toRow(header), toRow(separator), ...cells.slice(1).map(toRow)].join('\n')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Recursive walker
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function serializeChildren(node: Node, depth: number): string {
|
|
188
|
+
let out = ''
|
|
189
|
+
node.childNodes.forEach((child) => {
|
|
190
|
+
out += serializeNode(child, depth)
|
|
191
|
+
})
|
|
192
|
+
return out
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function serializeNode(node: Node, depth: number): string {
|
|
196
|
+
if (isTextNode(node)) return escapeText(collapseWhitespace(node.textContent ?? ''))
|
|
197
|
+
if (!isHTMLElement(node)) return ''
|
|
198
|
+
|
|
199
|
+
const tag = node.tagName
|
|
200
|
+
switch (tag) {
|
|
201
|
+
case 'SCRIPT':
|
|
202
|
+
case 'STYLE':
|
|
203
|
+
case 'NOSCRIPT':
|
|
204
|
+
case 'HEAD':
|
|
205
|
+
case 'TITLE':
|
|
206
|
+
return ''
|
|
207
|
+
case 'BR':
|
|
208
|
+
return '\n'
|
|
209
|
+
case 'HR':
|
|
210
|
+
return '\n\n---\n\n'
|
|
211
|
+
case 'H1':
|
|
212
|
+
case 'H2':
|
|
213
|
+
case 'H3':
|
|
214
|
+
case 'H4':
|
|
215
|
+
case 'H5':
|
|
216
|
+
case 'H6':
|
|
217
|
+
return `\n\n${'#'.repeat(Number(tag[1]))} ${serializeChildren(node, depth).trim()}\n\n`
|
|
218
|
+
case 'P':
|
|
219
|
+
return `\n\n${serializeChildren(node, depth).trim()}\n\n`
|
|
220
|
+
case 'DIV':
|
|
221
|
+
return `\n${serializeChildren(node, depth).trim()}\n`
|
|
222
|
+
case 'BLOCKQUOTE':
|
|
223
|
+
return serializeBlockquote(node, depth)
|
|
224
|
+
case 'UL':
|
|
225
|
+
case 'OL':
|
|
226
|
+
return `\n\n${serializeList(node, depth)}\n\n`
|
|
227
|
+
case 'LI':
|
|
228
|
+
// A stray <li> outside a list wrapper — emit its content as a line.
|
|
229
|
+
return `${serializeChildren(node, depth).trim()}\n`
|
|
230
|
+
case 'PRE':
|
|
231
|
+
return serializePre(node)
|
|
232
|
+
case 'CODE':
|
|
233
|
+
// Inline code only: <pre> handles its own <code> via textContent.
|
|
234
|
+
return serializeInlineCode(node)
|
|
235
|
+
case 'A':
|
|
236
|
+
return serializeAnchor(node, depth)
|
|
237
|
+
case 'IMG':
|
|
238
|
+
return serializeImage(node)
|
|
239
|
+
case 'TABLE':
|
|
240
|
+
return `\n\n${serializeTable(node, depth)}\n\n`
|
|
241
|
+
default: {
|
|
242
|
+
// Inline emphasis (B/STRONG/I/EM/S/DEL + styled SPAN/FONT) and the
|
|
243
|
+
// "span soup" unwrap case both resolve here: emphasis markers when the
|
|
244
|
+
// tag or inline style is meaningful, otherwise a bare unwrap.
|
|
245
|
+
const { prefix, suffix } = inlineEmphasis(node)
|
|
246
|
+
return prefix + serializeChildren(node, depth) + suffix
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Output normalization
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
/** Trims trailing spaces and caps consecutive blank lines at one. */
|
|
256
|
+
function normalizeOutput(markdown: string): string {
|
|
257
|
+
return markdown
|
|
258
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
259
|
+
.replace(/\n[ \t]*\n[ \t\n]*/g, '\n\n')
|
|
260
|
+
.trim()
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Public API
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Converts an HTML string to markdown source text. Returns '' for empty or
|
|
269
|
+
* body-less input. Block markdown (headings, lists, quotes, fences, tables,
|
|
270
|
+
* links) is emitted as literal markdown text — that is the editor's intended
|
|
271
|
+
* display; only `*`/`**`/`***` and bare URLs get visually decorated inline.
|
|
272
|
+
*/
|
|
273
|
+
export function htmlToMarkdown(html: string): string {
|
|
274
|
+
if (!html) return ''
|
|
275
|
+
const doc = new DOMParser().parseFromString(html, 'text/html')
|
|
276
|
+
if (!doc.body) return ''
|
|
277
|
+
return normalizeOutput(serializeChildren(doc.body, 0))
|
|
278
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../lib/utils.ts'
|
|
4
|
+
import { RemoveButton } from './remove-button.tsx'
|
|
5
|
+
import type { PromptAreaImage } from './types.ts'
|
|
6
|
+
|
|
7
|
+
type ImageStripProps = {
|
|
8
|
+
images: PromptAreaImage[]
|
|
9
|
+
onRemove?: (image: PromptAreaImage) => void
|
|
10
|
+
onClick?: (image: PromptAreaImage) => void
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ImageStrip({ images, onRemove, onClick, className }: ImageStripProps) {
|
|
15
|
+
if (images.length === 0) return null
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className={cn('flex flex-wrap gap-2', className)} role="list" aria-label="Attached images">
|
|
19
|
+
{images.map((image) => (
|
|
20
|
+
<div
|
|
21
|
+
key={image.id}
|
|
22
|
+
role="listitem"
|
|
23
|
+
className={cn(
|
|
24
|
+
'border-border relative h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border',
|
|
25
|
+
onClick && 'cursor-pointer',
|
|
26
|
+
)}
|
|
27
|
+
onClick={() => onClick?.(image)}>
|
|
28
|
+
{/* eslint-disable-next-line @next/next/no-img-element -- registry component used outside Next.js */}
|
|
29
|
+
<img
|
|
30
|
+
src={image.url}
|
|
31
|
+
alt={image.alt ?? 'Attached image'}
|
|
32
|
+
className="h-full w-full object-cover"
|
|
33
|
+
/>
|
|
34
|
+
{image.loading && (
|
|
35
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
|
36
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
|
37
|
+
</div>
|
|
38
|
+
)}
|
|
39
|
+
{onRemove && (
|
|
40
|
+
<RemoveButton
|
|
41
|
+
onClick={() => onRemove(image)}
|
|
42
|
+
label={`Remove ${image.alt ?? 'image'}`}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendored from just-marketing/prompt-area (MIT) via its shadcn registry, so the
|
|
3
|
+
* popover/chips can be themed with this package's tokens. Upstream:
|
|
4
|
+
* https://github.com/just-marketing/prompt-area
|
|
5
|
+
*/
|
|
6
|
+
export { PromptArea } from './prompt-area.tsx'
|
|
7
|
+
export { usePromptAreaState } from './use-prompt-area-state.ts'
|
|
8
|
+
export { commandTrigger, mentionTrigger, hashtagTrigger } from './trigger-presets.ts'
|
|
9
|
+
export {
|
|
10
|
+
segmentsToPlainText,
|
|
11
|
+
plainTextToSegments,
|
|
12
|
+
isSegmentsEmpty,
|
|
13
|
+
getChipsByTrigger,
|
|
14
|
+
} from './segment-helpers.ts'
|
|
15
|
+
export type {
|
|
16
|
+
PromptAreaHandle,
|
|
17
|
+
PromptAreaProps,
|
|
18
|
+
Segment,
|
|
19
|
+
ChipSegment,
|
|
20
|
+
TextSegment,
|
|
21
|
+
TriggerConfig,
|
|
22
|
+
TriggerSuggestion,
|
|
23
|
+
} from './types.ts'
|