obsidian-image-copu/main.js

557 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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);
// 转换为 BlobJPEG 格式压缩效果更好)
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// 1. 代码块(先处理,避免内部语法被转换)
const codeBlocks = [];
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => {
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
codeBlocks.push(`<pre><code class="language-${lang || ''}">${escapeHtml(code.trim())}</code></pre>`);
return placeholder;
});
// 2. 行内代码
const inlineCodes = [];
html = html.replace(/`([^`]+)`/g, (match, code) => {
const placeholder = `___INLINE_CODE_${inlineCodes.length}___`;
inlineCodes.push(`<code>${escapeHtml(code)}</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 => `<th>${h}</th>`).join('');
// 处理表体
const rows = body.trim().split('\n').map(row => {
const cells = row.split('|').map(c => c.trim()).filter(c => c);
return '<tr>' + cells.map(c => `<td>${c}</td>`).join('') + '</tr>';
}).join('\n');
const tableHtml = `<table><thead><tr>${headerRow}</tr></thead><tbody>${rows}</tbody></table>`;
tables.push(tableHtml);
return placeholder;
});
// 4. 标题h1-h6
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// 5. 引用块
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// 合并连续的引用块
html = html.replace(/(<\/blockquote>\n<blockquote>)/g, '\n');
// 6. 分割线
html = html.replace(/^(?:---|\*\*\*|___)$/gm, '<hr />');
// 7. 列表处理(改进版)
// 无序列表
html = html.replace(/^[\*\-\+]\s+(.+)$/gm, '<li>$1</li>');
// 有序列表
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li class="ordered">$1</li>');
// 合并列表项
html = html.replace(/(<li>(?:(?!<li class="ordered">).)*?<\/li>\n?)+/g, '<ul>$&</ul>');
html = html.replace(/(<li class="ordered">.*?<\/li>\n?)+/g, (match) => {
return '<ol>' + match.replace(/ class="ordered"/g, '') + '</ol>';
});
// 8. 文本样式(顺序很重要)
// 粗体+斜体(***text*** 或 ___text___
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
// 粗体(**text** 或 __text__
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
// 斜体(*text* 或 _text_
html = html.replace(/\*([^\s*](?:.*?[^\s*])?)\*/g, '<em>$1</em>');
html = html.replace(/_([^\s_](?:.*?[^\s_])?)_/g, '<em>$1</em>');
// 删除线
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 高亮(==text==
html = html.replace(/==(.+?)==/g, '<mark>$1</mark>');
// 9. 链接 [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// 10. 段落处理(改进版)
const lines = html.split('\n');
const processed = [];
let inParagraph = false;
for (let line of lines) {
const trimmed = line.trim();
// 如果是块级元素或占位符,不需要包裹 <p>
if (trimmed.match(/^<(h[1-6]|ul|ol|li|blockquote|pre|hr|div|img|table|___)/)) {
if (inParagraph) {
processed.push('</p>');
inParagraph = false;
}
processed.push(line);
} else if (trimmed === '') {
if (inParagraph) {
processed.push('</p>');
inParagraph = false;
}
} else {
if (!inParagraph) {
processed.push('<p>');
inParagraph = true;
}
processed.push(line);
}
}
if (inParagraph) {
processed.push('</p>');
}
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(/<p>\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. 标准语法 ![alt](url) */
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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
/* 通用样式 - 适配多平台 */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
/* 标题样式 */
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h3 { font-size: 1.25em; }
/* 段落 */
p {
margin-top: 0;
margin-bottom: 16px;
}
/* 图片样式 - 关键优化 */
img {
max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px;
width: 100%;
height: auto;
display: block;
margin: 15px auto;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
/* 代码块 */
pre {
background-color: #f6f8fa;
border-radius: 6px;
padding: 16px;
overflow: auto;
margin: 16px 0;
}
code {
background-color: rgba(175, 184, 193, 0.2);
padding: 0.2em 0.4em;
border-radius: 6px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 85%;
}
pre code {
background-color: transparent;
padding: 0;
}
/* 引用块 */
blockquote {
padding: 0 1em;
color: #6a737d;
border-left: 0.25em solid #dfe2e5;
margin: 16px 0;
}
/* 列表 */
ul, ol {
padding-left: 2em;
margin: 16px 0;
}
li {
margin: 0.25em 0;
}
/* 分割线 */
hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
/* 链接 */
a {
color: #0366d6;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* 删除线 */
del {
text-decoration: line-through;
opacity: 0.7;
}
/* 高亮 */
mark {
background-color: #fff3cd;
padding: 0.2em 0;
}
/* 表格(如果有) */
table {
border-collapse: collapse;
width: 100%;
margin: 16px 0;
}
th, td {
border: 1px solid #dfe2e5;
padding: 6px 13px;
}
th {
background-color: #f6f8fa;
font-weight: 600;
}
</style>
</head>
<body>
${htmlContent}
</body>
</html>
`.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 `![${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 `<img src="data:${mime};base64,${base64Svg}" alt="${alt}" style="max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto;" />`;
}
// GIF 动图不压缩,保持动画效果
if (ext.toLowerCase() === 'gif') {
const data = await buf2base64(buf);
const altText = alt ? alt.replace(/"/g, '&quot;') : '';
const imgStyle = `max-width: ${IMAGE_CONFIG.DISPLAY_MAX_WIDTH}px; width: 100%; height: auto; display: block; margin: 15px auto;`;
return `<img src="data:${mime};base64,${data}" alt="${altText}" style="${imgStyle}" />`;
}
// 处理其他图片格式:压缩图片
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, '&quot;') : '';
return `<img src="data:${finalMime};base64,${finalData}" alt="${altText}" style="${imgStyle}" />`;
} 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() {}
};