11
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export interface PastedImage {
|
||||
mediaType: string
|
||||
/** raw base64 (no `data:...;base64,` prefix) — what we send to claude */
|
||||
base64: string
|
||||
/** `data:...;base64,...` form for `<img src>` */
|
||||
previewDataUrl: string
|
||||
}
|
||||
|
||||
interface PastedImageWithId extends PastedImage {
|
||||
id: string
|
||||
/** Monotonically increasing index used for the `[Image #N]` token. */
|
||||
number: number
|
||||
}
|
||||
|
||||
interface PastedText {
|
||||
id: string
|
||||
text: string
|
||||
lines: number
|
||||
}
|
||||
|
||||
const PASTE_LINE_THRESHOLD = 5
|
||||
|
||||
/** Mirror of the extension-side ClaudeProfile in src/cc/profiles.ts. */
|
||||
export interface ClaudeProfile {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onCancel: () => void
|
||||
onSubmit: (userRequest: string, images: PastedImage[], profilePath?: string) => void
|
||||
profiles: ClaudeProfile[]
|
||||
defaultProfileName?: string
|
||||
}
|
||||
|
||||
function readClipboardImage(file: File): Promise<PastedImage | null> {
|
||||
return new Promise((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result
|
||||
if (typeof result !== 'string') {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
// result shape: "data:image/png;base64,iVBORw0K..."
|
||||
const commaIdx = result.indexOf(',')
|
||||
const header = commaIdx > 0 ? result.slice(0, commaIdx) : ''
|
||||
const base64 = commaIdx > 0 ? result.slice(commaIdx + 1) : ''
|
||||
const mediaMatch = header.match(/^data:([^;]+);base64$/)
|
||||
const mediaType = mediaMatch ? mediaMatch[1] : file.type || 'image/png'
|
||||
resolve({ mediaType, base64, previewDataUrl: result })
|
||||
}
|
||||
reader.onerror = () => resolve(null)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfileName }: Props) {
|
||||
const [value, setValue] = useState('')
|
||||
const [images, setImages] = useState<PastedImageWithId[]>([])
|
||||
const [pastedTexts, setPastedTexts] = useState<PastedText[]>([])
|
||||
const [nextImageNum, setNextImageNum] = useState(1)
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setValue('')
|
||||
setImages([])
|
||||
setPastedTexts([])
|
||||
setNextImageNum(1)
|
||||
setSelectedProfile(null)
|
||||
return
|
||||
}
|
||||
// Pick default profile when the modal opens. Prefer the one matching
|
||||
// `defaultProfileName`, otherwise fall back to the first alphabetically.
|
||||
if (profiles.length > 0) {
|
||||
const match = defaultProfileName
|
||||
? profiles.find(p => p.name === defaultProfileName)
|
||||
: undefined
|
||||
setSelectedProfile(match ? match.name : profiles[0].name)
|
||||
}
|
||||
else {
|
||||
setSelectedProfile(null)
|
||||
}
|
||||
const t = setTimeout(() => textareaRef.current?.focus(), 0)
|
||||
function handleKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape')
|
||||
onCancel()
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [open, onCancel, profiles, defaultProfileName])
|
||||
|
||||
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items)
|
||||
return
|
||||
const files: File[] = []
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const f = item.getAsFile()
|
||||
if (f)
|
||||
files.push(f)
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
// Intercept so the file binary doesn't end up pasted as garbled text.
|
||||
e.preventDefault()
|
||||
const parsed = await Promise.all(files.map(readClipboardImage))
|
||||
const ok = parsed.filter((p): p is PastedImage => p !== null)
|
||||
if (ok.length === 0)
|
||||
return
|
||||
// Assign sequential numbers; mirror pastedTexts and insert `[Image #N]`
|
||||
// tokens at the current caret so claude sees where each image belongs.
|
||||
let n = nextImageNum
|
||||
const enriched: PastedImageWithId[] = ok.map((img) => {
|
||||
const num = n
|
||||
n += 1
|
||||
return {
|
||||
...img,
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
number: num,
|
||||
}
|
||||
})
|
||||
setNextImageNum(n)
|
||||
setImages(prev => [...prev, ...enriched])
|
||||
const tokens = enriched.map(img => `[Image #${img.number}]`).join('')
|
||||
const ta = textareaRef.current
|
||||
const start = ta?.selectionStart ?? value.length
|
||||
const end = ta?.selectionEnd ?? value.length
|
||||
const newVal = value.slice(0, start) + tokens + value.slice(end)
|
||||
setValue(newVal)
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = start + tokens.length
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
// No images — check for large text paste.
|
||||
const text = e.clipboardData?.getData('text') ?? ''
|
||||
if (!text)
|
||||
return
|
||||
const lines = text.split('\n').length
|
||||
if (lines > PASTE_LINE_THRESHOLD) {
|
||||
e.preventDefault()
|
||||
const ta = textareaRef.current
|
||||
const start = ta?.selectionStart ?? value.length
|
||||
const end = ta?.selectionEnd ?? value.length
|
||||
const token = `[复制的 ${lines} 行文本]`
|
||||
const newVal = value.slice(0, start) + token + value.slice(end)
|
||||
setValue(newVal)
|
||||
setPastedTexts(prev => [...prev, {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
text,
|
||||
lines,
|
||||
}])
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = start + token.length
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [value, nextImageNum])
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>): void {
|
||||
if (e.key !== 'Backspace' && e.key !== 'Delete')
|
||||
return
|
||||
const ta = textareaRef.current
|
||||
if (!ta)
|
||||
return
|
||||
// Only handle when there's no selection (caret-only)
|
||||
if (ta.selectionStart !== ta.selectionEnd)
|
||||
return
|
||||
|
||||
const pos = ta.selectionStart
|
||||
const text = ta.value
|
||||
|
||||
function tryAtomicDelete(
|
||||
tokenRegex: RegExp,
|
||||
onMatch?: (match: RegExpExecArray) => void,
|
||||
): boolean {
|
||||
let match: RegExpExecArray | null = null
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((match = tokenRegex.exec(text)) !== null) {
|
||||
const tokenStart = match.index
|
||||
const tokenEnd = tokenStart + match[0].length
|
||||
const hitBackspace = e.key === 'Backspace' && pos > tokenStart && pos <= tokenEnd
|
||||
const hitDelete = e.key === 'Delete' && pos >= tokenStart && pos < tokenEnd
|
||||
if (!hitBackspace && !hitDelete)
|
||||
continue
|
||||
e.preventDefault()
|
||||
const next = text.slice(0, tokenStart) + text.slice(tokenEnd)
|
||||
setValue(next)
|
||||
onMatch?.(match)
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = textareaRef.current.selectionEnd = tokenStart
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (tryAtomicDelete(/\[复制的 \d+ 行文本\]/g))
|
||||
return
|
||||
tryAtomicDelete(/\[Image #(\d+)\]/g, (m) => {
|
||||
const num = Number.parseInt(m[1], 10)
|
||||
setImages(prev => prev.filter(i => i.number !== num))
|
||||
})
|
||||
}
|
||||
|
||||
function removeImage(id: string): void {
|
||||
const target = images.find(i => i.id === id)
|
||||
setImages(prev => prev.filter(i => i.id !== id))
|
||||
if (target) {
|
||||
const tok = `[Image #${target.number}]`
|
||||
setValue(prev => prev.split(tok).join(''))
|
||||
}
|
||||
}
|
||||
|
||||
if (!open)
|
||||
return null
|
||||
|
||||
const trimmed = value.trim()
|
||||
const canSubmit = trimmed.length > 0 || images.length > 0 || pastedTexts.length > 0
|
||||
|
||||
function handleSubmit(): void {
|
||||
if (!canSubmit)
|
||||
return
|
||||
const profilePath = selectedProfile
|
||||
? profiles.find(p => p.name === selectedProfile)?.path
|
||||
: undefined
|
||||
const pending = [...pastedTexts]
|
||||
const tokenRegex = /\[复制的 (\d+) 行文本\]/g
|
||||
const finalRequest = trimmed.replace(tokenRegex, (_match, linesStr) => {
|
||||
const lines = Number.parseInt(linesStr, 10)
|
||||
const idx = pending.findIndex(p => p.lines === lines)
|
||||
if (idx < 0)
|
||||
return ''
|
||||
const [taken] = pending.splice(idx, 1)
|
||||
return taken.text
|
||||
})
|
||||
onSubmit(
|
||||
finalRequest,
|
||||
images.map(({ mediaType, base64, previewDataUrl }) => ({ mediaType, base64, previewDataUrl })),
|
||||
profilePath,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="w-full max-w-xl rounded-md border border-[var(--vscode-panel-border)] bg-[var(--vscode-editor-background)] p-4 shadow-xl"
|
||||
>
|
||||
<h2 className="mb-3 text-base font-medium">新建工单</h2>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="用 Markdown 描述你的需求…(可 Ctrl+V 粘贴截图)"
|
||||
className="mb-2 h-72 w-full resize-none rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] p-2 font-mono text-xs text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]"
|
||||
/>
|
||||
|
||||
{profiles.length > 0 && (
|
||||
<fieldset className="mb-3 border-0 p-0">
|
||||
<legend className="mb-1 text-xs opacity-70">配置文件</legend>
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{profiles.map(p => (
|
||||
<label key={p.name} className="flex cursor-pointer items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="claude-profile"
|
||||
value={p.name}
|
||||
checked={selectedProfile === p.name}
|
||||
onChange={() => setSelectedProfile(p.name)}
|
||||
className="accent-[var(--vscode-focusBorder)]"
|
||||
/>
|
||||
<span>{p.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{images.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{images.map(img => (
|
||||
<div
|
||||
key={img.id}
|
||||
className="group relative h-16 w-16 overflow-hidden rounded border border-[var(--vscode-panel-border)]"
|
||||
>
|
||||
<img
|
||||
src={img.previewDataUrl}
|
||||
alt="pasted"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute left-0.5 top-0.5 rounded bg-black/60 px-1 text-[10px] leading-tight text-white/90"
|
||||
>
|
||||
#
|
||||
{img.number}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(img.id)}
|
||||
aria-label="移除"
|
||||
className="absolute right-0.5 top-0.5 grid size-4 place-items-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-3 py-1.5 text-xs text-[var(--vscode-button-secondaryForeground)] hover:bg-[var(--vscode-button-secondaryHoverBackground)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className="rounded bg-[var(--vscode-button-background)] px-3 py-1.5 text-xs text-[var(--vscode-button-foreground)] hover:bg-[var(--vscode-button-hoverBackground)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
确认
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user