双击 profile 值单元格改为打开富编辑弹窗:多行 textarea + Ctrl+V 粘贴截图, ⌘/Ctrl+Enter 或点遮罩保存、Esc 取消。值仍是单个字符串,图片存磁盘 (内容寻址 sha1 命名,去重),值里只留 markdown 引用,不撑大 profiles.json。 折叠单元格显示首行 + 含图时加 🖼 徽标。key/列头仍走原内联输入。 - 抽公用 lib/imagePaste:同步探测(preventDefault) + 异步读取,新建工单改用它、行为不变 - 新增扩展侧 profileAssets handler:存图(image/* 校验)/读图(挡路径穿越) - 新增 useProfileAssets(requestId 关联并发) 与 ProfileCellEditor 弹窗 - 消息两侧同步 profile-asset/save|load|saved|loaded
323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
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 `<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
|
|
}
|
|
|
|
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 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<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>
|
|
)
|
|
}
|