import { useCallback, useEffect, useRef, useState } from 'react' import { X } from 'lucide-react' import { getPastedImageFiles, readClipboardImages } from '../lib/imagePaste' export interface PastedImage { mediaType: string /** raw base64 (no `data:...;base64,` prefix) — what we send to claude */ base64: string /** `data:...;base64,...` form for `` */ 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 } export function NewIssueModal({ open, onCancel, onSubmit, profiles, defaultProfileName }: Props) { const [value, setValue] = useState('') const [images, setImages] = useState([]) const [pastedTexts, setPastedTexts] = useState([]) const [nextImageNum, setNextImageNum] = useState(1) const [selectedProfile, setSelectedProfile] = useState(null) const textareaRef = useRef(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) => { const files = getPastedImageFiles(e) if (files.length > 0) { // Intercept so the file binary doesn't end up pasted as garbled text. e.preventDefault() const ok = await readClipboardImages(files) 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): 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 (

新建工单