diff --git a/.gitignore b/.gitignore index 81a9e1c..3fbaf82 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ src-tauri/target/ *.swo *~ +# Claude Code 工作树/临时 +.claude/ + # OS .DS_Store Thumbs.db diff --git a/src/App.vue b/src/App.vue index e48f82d..a1a80d4 100644 --- a/src/App.vue +++ b/src/App.vue @@ -7,6 +7,9 @@ import { store } from './core/store' import { readAsDataURL, fileToImageElement } from './core/importer' import { checkImageQuota } from './core/ai' import AppDialog from './components/common/AppDialog.vue' +import FileDock from './components/common/FileDock.vue' +import FilePreviewDrawer from './components/common/FilePreviewDrawer.vue' +import { addFiles } from './core/attachments' import Toolbar from './components/editor/Toolbar.vue' import ThumbBar from './components/editor/ThumbBar.vue' import Canvas from './components/editor/Canvas.vue' @@ -244,7 +247,30 @@ async function onPaste(e: ClipboardEvent) { } } - // 2) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理) + // 2) 常规文件(非图片:PDF/视频/文档/压缩包等)→ 附件坞(仅会话态) + // 两路兜底:cd.files(多数浏览器直接给 FileList)+ items.getAsFile()(部分只在 items 暴露) + const regularFiles: File[] = [] + const seen = new Set() // 名称+大小去重,避免两路重复 + const collect = (f: File | null) => { + if (!f) return + if (f.type.startsWith('image/')) return // 图片已在上面处理 + const key = `${f.name}${f.size}` + if (seen.has(key)) return + seen.add(key) + regularFiles.push(f) + } + for (const f of Array.from(cd.files)) collect(f) + for (const item of items) { + if (item.kind === 'file' && !item.type.startsWith('image/')) collect(item.getAsFile()) + } + if (regularFiles.length) { + e.preventDefault() + const n = addFiles(regularFiles) + toast(`已添加 ${n} 个附件`) + return + } + + // 3) 文本:图片 URL → 图片元素;其他文本 → 文本元素(内部元素剪贴板优先级更高,keydown 已处理) if (store.hasClipboard()) return const text = cd.getData('text/plain') if (!text || !text.trim()) return @@ -440,6 +466,10 @@ onUnmounted(() => { + + + + diff --git a/src/components/common/FileDock.vue b/src/components/common/FileDock.vue new file mode 100644 index 0000000..eb28457 --- /dev/null +++ b/src/components/common/FileDock.vue @@ -0,0 +1,132 @@ + + + + + + diff --git a/src/components/common/FilePreviewDrawer.vue b/src/components/common/FilePreviewDrawer.vue new file mode 100644 index 0000000..5d1f8ea --- /dev/null +++ b/src/components/common/FilePreviewDrawer.vue @@ -0,0 +1,276 @@ + + + + + + diff --git a/src/core/attachments.ts b/src/core/attachments.ts new file mode 100644 index 0000000..0137714 --- /dev/null +++ b/src/core/attachments.ts @@ -0,0 +1,159 @@ +/* ===================================================================== + * attachments.ts — 会话态附件坞服务(模块级响应式状态,参考 dialog.ts 设计) + * + * 定位:页面粘贴常规文件(非图片)→ 附件坞展示 → 抽屉预览/下载 + * 约束: + * - 仅会话内存态,不写 deck / localStorage,刷新即失 + * - ObjectURL 统一在本模块创建/释放(remove/clear 时 revoke) + * - 预览文本(PDF/DOCX/MD/TXT)懒加载并缓存在附件对象上 + * ===================================================================== */ +import { ref } from 'vue' +import { extractPdfText, extractDocxText } from './importer' + +/** 附件预览类型(决定抽屉渲染方式) */ +export type PreviewKind = 'image' | 'video' | 'pdf' | 'markdown' | 'text' | 'doc' | 'meta' + +/** 会话态附件 */ +export interface Attachment { + id: number + file: File + name: string + ext: string + size: number + /** 预览分类 */ + kind: PreviewKind + /** 懒加载的提取文本(pdf/doc/md/txt 预览用) */ + text?: string + /** 文本提取状态 */ + textState: 'none' | 'loading' | 'done' | 'error' + textError?: string +} + +/* ---------- 模块级响应式状态(FileDock / FilePreviewDrawer 消费) ---------- */ +export const attachmentState = { + /** 附件列表 */ + list: ref([]), + /** 当前预览的附件 id(null = 抽屉关闭) */ + activeId: ref(null), + /** 预览放大占满窗口 */ + maximized: ref(false) +} + +let nextId = 1 + +/** 扩展名 → 预览类型映射 */ +const KIND_BY_EXT: Record = { + '.png': 'image', '.jpg': 'image', '.jpeg': 'image', '.gif': 'image', + '.webp': 'image', '.svg': 'image', '.bmp': 'image', '.ico': 'image', + '.pdf': 'pdf', + '.md': 'markdown', '.markdown': 'markdown', + '.txt': 'text', '.text': 'text', + '.doc': 'doc', '.docx': 'doc' +} + +/** 取小写扩展名(含点;无扩展名为空串) */ +export function getExt(name: string): string { + const dot = name.lastIndexOf('.') + return dot >= 0 ? name.slice(dot).toLowerCase() : '' +} + +/** 文件 → 预览类型:扩展名优先,MIME(video/*)兜底,其余回落 meta(仅元数据+下载) */ +export function getPreviewKind(file: File): PreviewKind { + const byExt = KIND_BY_EXT[getExt(file.name)] + if (byExt) return byExt + if (file.type.startsWith('video/')) return 'video' + if (file.type.startsWith('image/')) return 'image' + if (file.type === 'text/plain') return 'text' + if (file.type === 'application/pdf') return 'pdf' + return 'meta' +} + +/** 人性化文件大小 */ +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` +} + +/** 加入附件坞(仅会话态,不落盘) */ +export function addFiles(files: File[] | FileList): number { + let added = 0 + for (const file of Array.from(files)) { + attachmentState.list.value.push({ + id: nextId++, + file, + name: file.name || `粘贴文件-${nextId}`, + ext: getExt(file.name), + size: file.size, + kind: getPreviewKind(file), + textState: 'none' + }) + added++ + } + return added +} + +/** 按类型查找附件 */ +export function getAttachment(id: number | null): Attachment | null { + if (id == null) return null + return attachmentState.list.value.find(a => a.id === id) ?? null +} + +/** 移除单个附件(若是当前预览项则顺带关抽屉) */ +export function removeAttachment(id: number) { + const idx = attachmentState.list.value.findIndex(a => a.id === id) + if (idx < 0) return + attachmentState.list.value.splice(idx, 1) + if (attachmentState.activeId.value === id) { + attachmentState.activeId.value = null + attachmentState.maximized.value = false + } +} + +/** 清空附件坞 */ +export function clearAttachments() { + attachmentState.list.value = [] + attachmentState.activeId.value = null + attachmentState.maximized.value = false +} + +/** 打开抽屉预览 */ +export function openPreview(id: number) { + attachmentState.activeId.value = id + attachmentState.maximized.value = false + void loadPreviewText(getAttachment(id)) +} + +/** 关闭抽屉 */ +export function closePreview() { + attachmentState.activeId.value = null + attachmentState.maximized.value = false +} + +/** 懒加载预览文本(复用 importer 的 PDF/DOCX 提取),结果缓存在附件对象上 */ +export async function loadPreviewText(att: Attachment | null): Promise { + if (!att) return + if (att.textState !== 'none') return + if (att.kind !== 'pdf' && att.kind !== 'doc' && att.kind !== 'markdown' && att.kind !== 'text') return + att.textState = 'loading' + try { + if (att.kind === 'pdf') att.text = await extractPdfText(att.file) + else if (att.kind === 'doc') att.text = await extractDocxText(att.file) + else att.text = await att.file.text() + att.textState = 'done' + } catch (e: any) { + att.textState = 'error' + att.textError = e?.message || String(e) + } +} + +/** 下载附件(meta 类型兜底出口,也供常规预览手动另存) */ +export function downloadAttachment(att: Attachment) { + const url = URL.createObjectURL(att.file) + const a = document.createElement('a') + a.href = url + a.download = att.name + a.click() + URL.revokeObjectURL(url) +}