/* 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, '''); }; // 1. 代码块(先处理,避免内部语法被转换) const codeBlocks = []; html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`; codeBlocks.push(`
${escapeHtml(code.trim())}`);
return placeholder;
});
// 2. 行内代码
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `___INLINE_CODE_${inlineCodes.length}___`;
inlineCodes.push(`${escapeHtml(code)}`);
return placeholder;
});
// 3. 表格处理(Markdown 表格)
const tables = [];
html = html.replace(/^\|(.+)\|[ \t]*\n\|[-:\s|]+\|[ \t]*\n((?:\|.+\|[ \t]*\n?)*)/gm, (match, header, body) => {
const placeholder = `___TABLE_${tables.length}___`;
// 处理表头
const headers = header.split('|').map(h => h.trim()).filter(h => h);
const headerRow = headers.map(h => `$1'); // 合并连续的引用块 html = html.replace(/(<\/blockquote>\n
)/g, '\n'); // 6. 分割线 html = html.replace(/^(?:---|\*\*\*|___)$/gm, '
'); // 7. 列表处理(改进版) // 无序列表 html = html.replace(/^[\*\-\+]\s+(.+)$/gm, '$1 '); // 有序列表 html = html.replace(/^\d+\.\s+(.+)$/gm, '$1 '); // 合并列表项 html = html.replace(/((?:(?! ).)*?<\/li>\n?)+/g, ' $&
'); html = html.replace(/(.*?<\/li>\n?)+/g, (match) => { return ' ' + match.replace(/ class="ordered"/g, '') + '
'; }); // 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*](?:.*?[^\s*])?)\*/g, '$1'); html = html.replace(/_([^\s_](?:.*?[^\s_])?)_/g, '$1'); // 删除线 html = html.replace(/~~(.+?)~~/g, '$1'); // 高亮(==text==) html = html.replace(/==(.+?)==/g, '$1'); // 9. 链接 [text](url) html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); // 10. 段落处理(改进版) const lines = html.split('\n'); const processed = []; let inParagraph = false; for (let line of lines) { const trimmed = line.trim(); // 如果是块级元素或占位符,不需要包裹if (trimmed.match(/^<(h[1-6]|ul|ol|li|blockquote|pre|hr|div|img|table|___)/)) { if (inParagraph) { processed.push('
'); inParagraph = false; } processed.push(line); } else if (trimmed === '') { if (inParagraph) { processed.push(''); inParagraph = false; } } else { if (!inParagraph) { processed.push(''); inParagraph = true; } processed.push(line); } } if (inParagraph) { processed.push('
'); } html = processed.join('\n'); // 11. 还原占位符 // 还原表格 tables.forEach((table, i) => { html = html.replace(`___TABLE_${i}___`, table); }); // 还原代码块 codeBlocks.forEach((code, i) => { html = html.replace(`___CODE_BLOCK_${i}___`, code); }); // 还原行内代码 inlineCodes.forEach((code, i) => { html = html.replace(`___INLINE_CODE_${i}___`, code); }); // 12. 清理 html = html.replace(/\s*<\/p>/g, ''); html = html.replace(/\n{3,}/g, '\n\n'); return html; } /* ========== 复制全文 + 内嵌图片 ========== */ 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. 标准语法  */ md = await replaceAsync(md, /!\[([^\]]*)\]\(([^)]+)\)/g, async (_, alt, src) => { src = decodeURIComponent(src).trim(); return await inlineImage(src, alt); }); /* 3. 转换为 HTML */ const htmlContent = markdownToHtml(md); /* 4. 生成完整的 HTML 文档(包含样式) */ const fullHtml = `
${htmlContent} `.trim(); /* 5. 写入剪贴板 - 多格式支持 */ try { if (navigator.clipboard && window.ClipboardItem) { // 准备多种格式,提高平台兼容性 const clipboardData = { 'text/plain': new Blob([md], { type: 'text/plain' }), 'text/html': new Blob([fullHtml], { type: 'text/html' }) }; const clipboardItem = new ClipboardItem(clipboardData); await navigator.clipboard.write([clipboardItem]); new Notice('✅ 已复制全文(含图片和样式)!\n支持:飞书、微信公众号、知乎等平台'); } else { // 降级:仅支持纯文本 await navigator.clipboard.writeText(md); new Notice('⚠️ 已复制纯文本格式(浏览器不支持富文本)'); } } catch (err) { console.error('[bulk-copy] 复制失败', err); new Notice('❌ 复制失败,请检查浏览器权限设置'); } /* ----------- 单张图内嵌 ----------- */ 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 ``; } 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 ``; } // 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 `
`; } // 处理其他图片格式:压缩图片 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 `
`; } catch (e) { console.warn('[bulk-copy] 读取失败', imgName, e); return ``; } } } /* ========== 异步 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() {} };