/* eslint-disable */ const { Plugin, Notice, TFile } = require('obsidian'); /* ========== 图片优化配置 ========== */ const IMAGE_CONFIG = { // 图片压缩最大宽度(像素)- 超过此宽度的图片会被等比缩放 MAX_WIDTH: 800, // 图片压缩质量(0-1 之间,1 为最高质量) // 推荐值:0.8-0.85,在质量和文件大小之间取得良好平衡 QUALITY: 0.8, // 显示最大宽度(像素)- 图片在编辑器中的最大显示宽度 DISPLAY_MAX_WIDTH: 600 }; /* ========== 安全大图转 base64 ========== */ async function buf2base64(buf) { // 使用更直接的方式转换为 base64,避免数据损失 return new Promise((resolve, reject) => { const bytes = new Uint8Array(buf); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } resolve(btoa(binary)); }); } /* ========== 图片压缩和尺寸限制 ========== */ async function compressImage(buf, ext, maxWidth = IMAGE_CONFIG.MAX_WIDTH, quality = IMAGE_CONFIG.QUALITY) { return new Promise((resolve, reject) => { try { // 创建 Blob const mime = getMime(ext); const blob = new Blob([buf], { type: mime }); // 创建图片元素 const img = new Image(); const url = URL.createObjectURL(blob); img.onload = () => { // 释放临时 URL URL.revokeObjectURL(url); // 计算新尺寸(保持宽高比) let width = img.width; let height = img.height; if (width > maxWidth) { height = Math.round((height * maxWidth) / width); width = maxWidth; } // 如果图片本来就很小,且不需要转换格式,直接返回 if (width === img.width && (ext.toLowerCase() === 'jpg' || ext.toLowerCase() === 'jpeg' || ext.toLowerCase() === 'png')) { resolve({ data: buf, needsConversion: false }); return; } // 创建 Canvas 进行压缩 const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, width, height); // 转换为 Blob(JPEG 格式压缩效果更好) canvas.toBlob( (compressedBlob) => { if (!compressedBlob) { reject(new Error('图片压缩失败')); return; } // 将 Blob 转为 ArrayBuffer const reader = new FileReader(); reader.onload = () => { resolve({ data: reader.result, needsConversion: true, mime: 'image/jpeg' // 统一转为 JPEG }); }; reader.onerror = reject; reader.readAsArrayBuffer(compressedBlob); }, 'image/jpeg', // 使用 JPEG 格式 quality // 压缩质量 ); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')); }; img.src = url; } catch (err) { reject(err); } }); } /* ========== mime 映射 ========== */ const getMime = ext => ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff' }[ext.toLowerCase()] || 'application/octet-stream'); /* ========== Markdown 转 HTML(保留完整排版) ========== */ function markdownToHtml(md) { let html = md; // 转义 HTML 特殊字符(仅用于代码块) const escapeHtml = (text) => { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; // 保护已存在的 HTML 标签(如图片、已转换的元素) const protectedElements = []; html = html.replace(/<(img|pre|code|table|div)[^>]*>[\s\S]*?<\/\1>|<(img|br|hr)[^>]*\/?>/gi, (match) => { const id = protectedElements.length; protectedElements.push(match); return `\n\n`; }); // 1. 代码块(先处理,避免内部语法被转换) const codeBlocks = []; html = html.replace(/```[\s\S]*?```/g, (match) => { const id = codeBlocks.length; const langMatch = match.match(/```(\w+)?\n([\s\S]*?)```/); if (langMatch) { const lang = langMatch[1] || ''; const code = langMatch[2] || ''; codeBlocks.push(`
${escapeHtml(code.trim())}
`); } else { const code = match.replace(/```/g, '').trim(); codeBlocks.push(`
${escapeHtml(code)}
`); } return `\n\n`; }); // 2. 行内代码 const inlineCodes = []; html = html.replace(/`([^`\n]+)`/g, (match, code) => { const id = inlineCodes.length; inlineCodes.push(`${escapeHtml(code)}`); return ``; }); // 3. 表格处理(Markdown 表格) const tables = []; html = html.replace(/^\|(.+)\|[ \t]*$\n^\|[-:\s|]+\|[ \t]*$\n((?:^\|.+\|[ \t]*$\n?)*)/gm, (match, header, body) => { const id = tables.length; // 处理表头 const headers = header.split('|').map(h => h.trim()).filter(h => h); const headerRow = headers.map(h => `${h}`).join(''); // 处理表体 const bodyRows = body.trim().split('\n').filter(row => row.trim()); const rows = bodyRows.map(row => { const cells = row.split('|').map(c => c.trim()).filter(c => c); return '' + cells.map(c => `${c}`).join('') + ''; }).join('\n'); const tableHtml = `${headerRow}${rows}
`; tables.push(tableHtml); return `\n\n`; }); // 4. 标题(h1-h6)- 必须在行首 html = html.replace(/^######\s+(.+)$/gm, '
$1
'); html = html.replace(/^#####\s+(.+)$/gm, '
$1
'); html = html.replace(/^####\s+(.+)$/gm, '

$1

'); html = html.replace(/^###\s+(.+)$/gm, '

$1

'); html = html.replace(/^##\s+(.+)$/gm, '

$1

'); html = html.replace(/^#\s+(.+)$/gm, '

$1

'); // 5. 引用块 const lines = html.split('\n'); const processedLines = []; let inBlockquote = false; let blockquoteContent = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.match(/^>\s/)) { // 引用行 const content = line.replace(/^>\s?/, ''); blockquoteContent.push(content); inBlockquote = true; } else { // 非引用行 if (inBlockquote) { // 结束引用块 processedLines.push('
' + blockquoteContent.join('\n') + '
'); blockquoteContent = []; inBlockquote = false; } processedLines.push(line); } } // 处理末尾的引用块 if (inBlockquote && blockquoteContent.length > 0) { processedLines.push('
' + blockquoteContent.join('\n') + '
'); } html = processedLines.join('\n'); // 6. 分割线 html = html.replace(/^(?:---|___|\*\*\*)$/gm, '
'); // 7. 列表处理(改进版,支持嵌套) const listLines = html.split('\n'); const processedListLines = []; let inList = false; let listType = null; // 'ul' or 'ol' let listItems = []; for (let i = 0; i < listLines.length; i++) { const line = listLines[i]; const ulMatch = line.match(/^([\*\-\+])\s+(.+)$/); const olMatch = line.match(/^\d+\.\s+(.+)$/); if (ulMatch) { // 无序列表 if (!inList || listType !== 'ul') { // 开始新的无序列表 if (inList && listItems.length > 0) { processedListLines.push(`<${listType}>${listItems.join('')}`); } listType = 'ul'; inList = true; listItems = []; } listItems.push(`
  • ${ulMatch[2]}
  • `); } else if (olMatch) { // 有序列表 if (!inList || listType !== 'ol') { // 开始新的有序列表 if (inList && listItems.length > 0) { processedListLines.push(`<${listType}>${listItems.join('')}`); } listType = 'ol'; inList = true; listItems = []; } listItems.push(`
  • ${olMatch[1]}
  • `); } else { // 非列表行 if (inList && listItems.length > 0) { processedListLines.push(`<${listType}>${listItems.join('')}`); listItems = []; inList = false; listType = null; } processedListLines.push(line); } } // 处理末尾的列表 if (inList && listItems.length > 0) { processedListLines.push(`<${listType}>${listItems.join('')}`); } html = processedListLines.join('\n'); // 8. 文本样式(顺序很重要,先处理组合样式) // 粗体+斜体(***text*** 或 ___text___) html = html.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); html = html.replace(/___(.+?)___/g, '$1'); // 粗体(**text** 或 __text__) html = html.replace(/\*\*(.+?)\*\*/g, '$1'); html = html.replace(/__(.+?)__/g, '$1'); // 斜体(*text* 或 _text_) html = html.replace(/\*([^\s*][^*]*?)\*/g, '$1'); html = html.replace(/\b_([^\s_][^_]*?)_\b/g, '$1'); // 删除线 html = html.replace(/~~(.+?)~~/g, '$1'); // 高亮(==text==) html = html.replace(/==(.+?)==/g, '$1'); // 9. 链接 [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // 10. 段落处理(改进版) const finalLines = html.split('\n'); const finalProcessed = []; let inParagraph = false; let paragraphLines = []; for (let i = 0; i < finalLines.length; i++) { const line = finalLines[i]; const trimmed = line.trim(); // 检查是否是块级元素或占位符 const isBlockElement = trimmed.match(/^<(h[1-6]|ul|ol|blockquote|pre|hr|table|div)/) || trimmed.match(/^$/); if (isBlockElement || trimmed === '') { // 块级元素或空行 if (inParagraph && paragraphLines.length > 0) { // 结束当前段落 finalProcessed.push('

    ' + paragraphLines.join('\n') + '

    '); paragraphLines = []; inParagraph = false; } if (trimmed !== '') { finalProcessed.push(line); } } else { // 普通文本行 if (!inParagraph) { inParagraph = true; } paragraphLines.push(line); } } // 处理末尾的段落 if (inParagraph && paragraphLines.length > 0) { finalProcessed.push('

    ' + paragraphLines.join('\n') + '

    '); } html = finalProcessed.join('\n'); // 11. 换行处理(Markdown 中的双空格 + 换行) html = html.replace(/ \n/g, '
    \n'); // 12. 还原占位符(按顺序还原,使用正则全局替换) // 先还原块级元素(表格、代码块) tables.forEach((table, i) => { html = html.replace(new RegExp(``, 'g'), table); }); codeBlocks.forEach((code, i) => { html = html.replace(new RegExp(``, 'g'), code); }); protectedElements.forEach((element, i) => { html = html.replace(new RegExp(``, 'g'), element); }); // 最后还原行内元素(行内代码) inlineCodes.forEach((code, i) => { html = html.replace(new RegExp(``, 'g'), code); }); // 13. 清理多余的空标签和空行 html = html.replace(/

    \s*<\/p>/g, ''); html = html.replace(/

    (\s*
    \s*)+<\/p>/g, ''); html = html.replace(/\n{3,}/g, '\n\n'); // 调试:检查是否还有未还原的占位符 const remainingPlaceholders = html.match(//g); if (remainingPlaceholders) { console.warn('[bulk-copy] ⚠️ 发现未还原的占位符:', remainingPlaceholders); console.warn('[bulk-copy] 代码块数量:', codeBlocks.length); console.warn('[bulk-copy] 表格数量:', tables.length); console.warn('[bulk-copy] 行内代码数量:', inlineCodes.length); console.warn('[bulk-copy] 受保护元素数量:', protectedElements.length); } else { console.log('[bulk-copy] ✅ 所有占位符已成功还原'); console.log('[bulk-copy] 转换统计 - 代码块:', codeBlocks.length, '表格:', tables.length, '图片:', protectedElements.length); } return html.trim(); } /* ========== 复制全文 + 内嵌图片 ========== */ async function copyActiveFileWithImages(plugin) { const file = plugin.app.workspace.getActiveFile(); if (!file || file.extension !== 'md') { new Notice('请先打开一篇 Markdown 笔记'); return; } let md = await plugin.app.vault.read(file); const baseFolder = file.parent.path; const assetsFolder = `${baseFolder}/${file.basename}.assets`; /* 1. Wiki 语法 ![[xxx.png]] */ md = await replaceAsync(md, /!\[\[([^\]|]+?)(?:\|[^\]]+)?\]\]/g, async (_, raw) => { const name = raw.split('|')[0].trim(); return await inlineImage(name, ''); }); /* 2. 标准语法 ![alt](url) */ md = await replaceAsync(md, /!\[([^\]]*)\]\(([^)]+)\)/g, async (_, alt, src) => { src = decodeURIComponent(src).trim(); return await inlineImage(src, alt); }); /* 3. 转换为 HTML */ let htmlContent = markdownToHtml(md); // 调试:输出转换后的 HTML(开发时可以查看) console.log('[bulk-copy] HTML Content Preview (前 500 字符):', htmlContent.substring(0, 500)); console.log('[bulk-copy] HTML Content Preview (后 500 字符):', htmlContent.substring(Math.max(0, htmlContent.length - 500))); /* 4. 生成富文本编辑器兼容的 HTML(带内联样式) */ const styledHtml = `

    ${htmlContent}
    `; /* 5. 生成完整的 HTML 文档(用于剪贴板,某些平台需要) */ const fullHtml = ` ${htmlContent} `.trim(); /* 6. 写入剪贴板 - 多格式优化 */ try { if (navigator.clipboard && window.ClipboardItem) { // 准备多种格式,提高平台兼容性 // 使用 styledHtml(简化版)为主,fullHtml 为备用 const clipboardData = { 'text/plain': new Blob([md], { type: 'text/plain' }), 'text/html': new Blob([styledHtml], { type: 'text/html' }) }; const clipboardItem = new ClipboardItem(clipboardData); await navigator.clipboard.write([clipboardItem]); console.log('[bulk-copy] 复制成功,HTML 长度:', styledHtml.length); new Notice('✅ 已复制全文(含图片和样式)!\n支持:飞书、微信公众号、知乎等平台'); } else { // 降级方案:尝试使用传统方法 const clipboardDiv = document.createElement('div'); clipboardDiv.style.position = 'fixed'; clipboardDiv.style.left = '-9999px'; clipboardDiv.innerHTML = styledHtml; document.body.appendChild(clipboardDiv); const range = document.createRange(); range.selectNodeContents(clipboardDiv); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); try { document.execCommand('copy'); new Notice('✅ 已复制全文(使用传统方式)'); } catch (e) { await navigator.clipboard.writeText(md); new Notice('⚠️ 已复制纯文本格式'); } document.body.removeChild(clipboardDiv); selection.removeAllRanges(); } } catch (err) { console.error('[bulk-copy] 复制失败', err); new Notice('❌ 复制失败: ' + err.message); } /* ----------- 单张图内嵌 ----------- */ async function inlineImage(imgName, alt) { const candidates = [ `${assetsFolder}/${imgName}`, `${baseFolder}/${imgName}`, imgName.startsWith('/') ? imgName.slice(1) : imgName ]; let imgFile = candidates .map(p => plugin.app.vault.getAbstractFileByPath(p)) .find(f => f instanceof TFile); if (!imgFile) { // 全库搜文件名 const all = plugin.app.vault.getFiles(); imgFile = all.find(f => f.name === imgName); } if (!imgFile) { console.warn('[bulk-copy] 未找到图片文件', imgName); return `![${alt}](${imgName})`; } try { const buf = await plugin.app.vault.readBinary(imgFile); const ext = imgFile.extension; const mime = getMime(ext); // 特殊处理 SVG 文件(不压缩) if (ext.toLowerCase() === 'svg') { // 对于 SVG,直接读取文本内容 const svgContent = await plugin.app.vault.read(imgFile); const base64Svg = btoa(unescape(encodeURIComponent(svgContent))); // 限制 SVG 最大宽度 return `${alt}`; } // GIF 动图不压缩,保持动画效果 if (ext.toLowerCase() === 'gif') { const data = await buf2base64(buf); const altText = alt ? alt.replace(/"/g, '"') : ''; const imgStyle = `max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto;`; return `${altText}`; } // 处理其他图片格式:压缩图片 const compressed = await compressImage(buf, ext, IMAGE_CONFIG.MAX_WIDTH, IMAGE_CONFIG.QUALITY); let finalMime = mime; let finalData; if (compressed.needsConversion) { // 图片已被压缩,使用压缩后的数据 finalMime = compressed.mime; finalData = await buf2base64(compressed.data); } else { // 图片无需压缩,使用原始数据 finalData = await buf2base64(compressed.data); } if (!finalData) { throw new Error('Base64 转换失败'); } // 添加内联样式,限制图片显示宽度 // 某些平台(如微信公众号)会过滤外部样式,所以使用内联样式 const imgStyle = `max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto; border-radius: 4px;`; const altText = alt ? alt.replace(/"/g, '"') : ''; return `${altText}`; } catch (e) { console.warn('[bulk-copy] 读取失败', imgName, e); return `![${alt}](${imgName})`; } } } /* ========== 异步 replace ========== */ async function replaceAsync(str, regex, asyncFn) { const promises = []; str.replace(regex, (match, ...args) => { promises.push(asyncFn(match, ...args)); return match; }); const reps = await Promise.all(promises); return str.replace(regex, () => reps.shift()); } /* ========== 插件入口 ========== */ module.exports = class BulkCopyImagesPlugin extends Plugin { onload() { this.addCommand({ id: 'bulk-copy-with-images', name: '复制全文并内嵌本地图片', hotkeys: [{ modifiers: ['Ctrl', 'Shift'], key: 'c' }], callback: () => copyActiveFileWithImages(this) }); } onunload() {} };