Files
superwork/vscode/webview-ui/src/components/NewIssueModal.tsx
T
cruldra cace658ac1 feat(vscode): profile 值编辑器改为多行+图片粘贴(图片落 .spx/profile-assets)
双击 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
2026-07-03 16:55:40 +08:00

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>
)
}