obsidian-image-copu/main.js
taiyi 5a4b3e66bd 1. 支持markdown和富文本编辑器
2. 完善代码块和图片复制粘贴
2025-10-16 22:15:22 +08:00

701 lines
22 KiB
JavaScript
Raw Permalink 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;');
};
// 保护已存在的 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<!--PROTECTED${id}-->\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(`<pre><code class="language-${lang}">${escapeHtml(code.trim())}</code></pre>`);
} else {
const code = match.replace(/```/g, '').trim();
codeBlocks.push(`<pre><code>${escapeHtml(code)}</code></pre>`);
}
return `\n<!--CODEBLOCK${id}-->\n`;
});
// 2. 行内代码
const inlineCodes = [];
html = html.replace(/`([^`\n]+)`/g, (match, code) => {
const id = inlineCodes.length;
inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
return `<!--INLINECODE${id}-->`;
});
// 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 => `<th>${h}</th>`).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 '<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 `\n<!--TABLE${id}-->\n`;
});
// 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. 引用块
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('<blockquote>' + blockquoteContent.join('\n') + '</blockquote>');
blockquoteContent = [];
inBlockquote = false;
}
processedLines.push(line);
}
}
// 处理末尾的引用块
if (inBlockquote && blockquoteContent.length > 0) {
processedLines.push('<blockquote>' + blockquoteContent.join('\n') + '</blockquote>');
}
html = processedLines.join('\n');
// 6. 分割线
html = html.replace(/^(?:---|___|\*\*\*)$/gm, '<hr />');
// 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}>`);
}
listType = 'ul';
inList = true;
listItems = [];
}
listItems.push(`<li>${ulMatch[2]}</li>`);
} else if (olMatch) {
// 有序列表
if (!inList || listType !== 'ol') {
// 开始新的有序列表
if (inList && listItems.length > 0) {
processedListLines.push(`<${listType}>${listItems.join('')}</${listType}>`);
}
listType = 'ol';
inList = true;
listItems = [];
}
listItems.push(`<li>${olMatch[1]}</li>`);
} else {
// 非列表行
if (inList && listItems.length > 0) {
processedListLines.push(`<${listType}>${listItems.join('')}</${listType}>`);
listItems = [];
inList = false;
listType = null;
}
processedListLines.push(line);
}
}
// 处理末尾的列表
if (inList && listItems.length > 0) {
processedListLines.push(`<${listType}>${listItems.join('')}</${listType}>`);
}
html = processedListLines.join('\n');
// 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*][^*]*?)\*/g, '<em>$1</em>');
html = html.replace(/\b_([^\s_][^_]*?)_\b/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 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(/^<!--(?:CODEBLOCK|TABLE|PROTECTED)\d+-->$/);
if (isBlockElement || trimmed === '') {
// 块级元素或空行
if (inParagraph && paragraphLines.length > 0) {
// 结束当前段落
finalProcessed.push('<p>' + paragraphLines.join('\n') + '</p>');
paragraphLines = [];
inParagraph = false;
}
if (trimmed !== '') {
finalProcessed.push(line);
}
} else {
// 普通文本行
if (!inParagraph) {
inParagraph = true;
}
paragraphLines.push(line);
}
}
// 处理末尾的段落
if (inParagraph && paragraphLines.length > 0) {
finalProcessed.push('<p>' + paragraphLines.join('\n') + '</p>');
}
html = finalProcessed.join('\n');
// 11. 换行处理Markdown 中的双空格 + 换行)
html = html.replace(/ \n/g, '<br>\n');
// 12. 还原占位符(按顺序还原,使用正则全局替换)
// 先还原块级元素(表格、代码块)
tables.forEach((table, i) => {
html = html.replace(new RegExp(`<!--TABLE${i}-->`, 'g'), table);
});
codeBlocks.forEach((code, i) => {
html = html.replace(new RegExp(`<!--CODEBLOCK${i}-->`, 'g'), code);
});
protectedElements.forEach((element, i) => {
html = html.replace(new RegExp(`<!--PROTECTED${i}-->`, 'g'), element);
});
// 最后还原行内元素(行内代码)
inlineCodes.forEach((code, i) => {
html = html.replace(new RegExp(`<!--INLINECODE${i}-->`, 'g'), code);
});
// 13. 清理多余的空标签和空行
html = html.replace(/<p>\s*<\/p>/g, '');
html = html.replace(/<p>(\s*<br>\s*)+<\/p>/g, '');
html = html.replace(/\n{3,}/g, '\n\n');
// 调试:检查是否还有未还原的占位符
const remainingPlaceholders = html.match(/<!--(?:CODEBLOCK|TABLE|PROTECTED|INLINECODE)\d+-->/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 = `<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 900px;">
${htmlContent}
</div>`;
/* 5. 生成完整的 HTML 文档(用于剪贴板,某些平台需要) */
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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();
/* 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 `<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() {}
};