✨ 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
This commit is contained in:
cruldra committed 2026-07-03 16:55:40 +08:00
1 parent 35085f72f7
commit cace658ac1
9 files changed
+549 -82

No files matched your search

@@ -10,8 +10,9 @@
*/
import type { ProfileRow, ProfilesData } from '../lib/messages'
import { ExternalLink, Folder, Plus } from 'lucide-react'
import { ExternalLink, Folder, Image as ImageIcon, Plus } from 'lucide-react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ProfileCellEditor } from './ProfileCellEditor'
interface ProfileGridProps {
data: ProfilesData
@@ -33,6 +34,11 @@ type EditTarget
const URL_RE = /^(?:https?:\/\/|git@)/i
const WIN_PATH_RE = /^[A-Z]:[\\/]/i
/** 值里是否含粘贴图片的引用,用来给折叠单元格加 🖼 徽标。 */
function hasImageRef(value: string): boolean {
return /!\[[^\]]*\]\(\.spx\/profile-assets\//.test(value)
}
function classifyValue(value: string): 'url' | 'path' | null {
const v = value.trim()
if (!v)
@@ -51,6 +57,8 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
const [editing, setEditing] = useState<EditTarget | null>(null)
const [draft, setDraft] = useState<string>('')
const [menu, setMenu] = useState<MenuState | null>(null)
// 值单元格改用富编辑弹窗(多行 + 图片),key/列头仍走内联 input。
const [cellEdit, setCellEdit] = useState<{ row: number, profile: string } | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
useLayoutEffect(() => {
@@ -322,6 +330,7 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
setDraft={setDraft}
openKeyMenu={openKeyMenu}
onOpen={onOpen}
onEditCell={profile => setCellEdit({ row: rIdx, profile })}
/>
))}
@@ -366,6 +375,20 @@ export function ProfileGrid({ data, onSave, onOpen }: ProfileGridProps) {
))}
</div>
)}
{/* 值单元格富编辑弹窗:双击值格打开,多行 + 图片粘贴 */}
{cellEdit && (
<ProfileCellEditor
keyLabel={rows[cellEdit.row]?.key ?? ''}
profileLabel={cellEdit.profile}
initialValue={rows[cellEdit.row]?.values[cellEdit.profile] ?? ''}
onSave={(val) => {
commitEdit({ kind: 'cell', row: cellEdit.row, profile: cellEdit.profile }, val)
setCellEdit(null)
}}
onCancel={() => setCellEdit(null)}
/>
)}
</div>
)
}
@@ -385,6 +408,7 @@ interface RowFragmentProps {
setDraft: (v: string) => void
openKeyMenu: (e: React.MouseEvent, row: number) => void
onOpen: (value: string) => void
onEditCell: (profile: string) => void
}
function RowFragment({
@@ -402,6 +426,7 @@ function RowFragment({
setDraft,
openKeyMenu,
onOpen,
onEditCell,
}: RowFragmentProps) {
return (
<>
@@ -438,57 +463,43 @@ function RowFragment({
</div>
{profiles.map((profile, col) => {
const value = row.values[profile] ?? ''
const isEditing = editing && editing.kind === 'cell' && editing.row === rIdx && editing.profile === profile
const kind = classifyValue(value)
// 折叠态只显示首行;多行/含图的值不再当 URL/path 处理(不显示打开图标)。
const singleLine = !value.includes('\n')
const firstLine = singleLine ? value : value.split('\n', 1)[0]
const kind = singleLine ? classifyValue(value) : null
const withImage = hasImageRef(value)
return (
<div
key={`r${rIdx}-c${col}`}
className={`${cellBase} ${editableHover} group px-2`}
onDoubleClick={() => beginEdit({ kind: 'cell', row: rIdx, profile })}
title="双击编辑"
onDoubleClick={() => onEditCell(profile)}
title="双击编辑(多行 + 图片)"
>
{isEditing
? (
<input
ref={inputRef}
type="text"
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={finishEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
finishEdit()
}
else if (e.key === 'Escape') {
e.preventDefault()
cancelEdit()
}
}}
className="w-full bg-transparent px-0 py-0 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
/>
)
: (
<div className="flex w-full min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">
{value || <span className="opacity-40">(空)</span>}
</span>
{kind && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onOpen(value)
}}
title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
>
{kind === 'url' ? <ExternalLink className="size-3.5" /> : <Folder className="size-3.5" />}
</button>
)}
</div>
)}
<div className="flex w-full min-w-0 items-center gap-1.5">
<span className="min-w-0 flex-1 truncate">
{firstLine || <span className="opacity-40">(空)</span>}
</span>
{withImage && (
<ImageIcon
className="size-3.5 shrink-0 text-[var(--vscode-descriptionForeground)]"
aria-label="含图片"
/>
)}
{kind && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onOpen(value)
}}
title={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
aria-label={kind === 'url' ? '在浏览器打开' : '在系统中打开'}
className="grid size-5 shrink-0 place-items-center rounded opacity-0 transition-opacity hover:bg-[var(--vscode-toolbar-hoverBackground,var(--vscode-list-hoverBackground))] hover:opacity-100 group-hover:opacity-70"
>
{kind === 'url' ? <ExternalLink className="size-3.5" /> : <Folder className="size-3.5" />}
</button>
)}
</div>
</div>
)
})}