bruce2431_obsidian-chat-bub.../src/chat-view.ts
bruce2431 bbdbbf0247 v1.0.0: Obsidian 聊天记录气泡渲染插件
- 检测 #聊天记录 标签自动识别
- 解析格式: [发送者] YYYY-MM-DD HH:MM:SS
- 微信风格气泡: 对方浅灰 #f2f3f5 / 自己微信绿 #95ec69
- 引用回复灰色引用条、合并转发可展开卡片
- Obsidian ![[文件]] 内部链接转图片/音频/视频
- 全屏 fixed overlay,零触碰 Obsidian DOM
- Ctrl+P 命令渲染,Esc/✕ 退出
- 基于 obsidianmd/obsidian-sample-plugin 模板
2026-06-15 17:25:04 +08:00

118 lines
3.8 KiB
TypeScript

/**
* Chat View - 气泡对话框渲染器
* 将解析后的聊天消息渲染为微信风格气泡 UI
*/
import { parseChatLog, ChatMessage, QuoteReply, MergeForward } from './chat-parser';
export function renderChatLog(markdown: string): string {
const { preamble, messages } = parseChatLog(markdown);
let html = '';
if (preamble) {
html += `<div class="chat-preamble">${escapeHtml(preamble.replace(/\n/g, '<br>'))}</div>`;
}
if (messages.length > 0) {
html += '<div class="chat-container">';
for (const msg of messages) {
const isSelf = isSelfMessage(msg.name);
const side = isSelf ? 'self' : 'other';
const bodyHtml = renderMessageBody(msg);
html += `<div class="chat-msg ${side}">`;
html += `<div class="chat-meta">${escapeHtml(msg.name)} · ${msg.time}</div>`;
html += `<div class="chat-bubble">${bodyHtml}</div>`;
html += '</div>';
}
html += '</div>';
}
return html;
}
function isSelfMessage(name: string): boolean {
const selfNames = ['bruceMTY', '我', 'me', '自己'];
return selfNames.some(n => name.toLowerCase() === n.toLowerCase());
}
function renderMessageBody(msg: ChatMessage): string {
let html = '';
for (const part of msg.body) {
if (typeof part === 'string') {
html += renderPlainText(part) + '\n';
} else if (part.type === 'quote-reply') {
html += renderQuoteReply(part);
} else if (part.type === 'merge-forward') {
html += renderMergeForward(part);
}
}
return html;
}
function renderPlainText(text: string): string {
let result = escapeHtml(text);
// Obsidian 内部链接 ![[文件名.ext|宽度]] → <img> / <audio> / <video>
result = result.replace(/!\[\[(.+?)(?:\|(\d+))?\]\]/g, (_m, file: string, w: string) => {
file = file.trim();
const ext = file.split('.').pop()?.toLowerCase() || '';
if (['mp3', 'm4a', 'wav', 'ogg', 'aac'].includes(ext)) {
return `<div class="chat-media"><audio controls src="${encodeURI(file)}" style="max-width:260px;height:32px;" onerror="this.style.display='none'"></audio></div>`;
}
if (['mp4', 'webm', 'mov'].includes(ext)) {
return `<div class="chat-media"><video controls src="${encodeURI(file)}" style="max-width:280px;max-height:200px;" onerror="this.style.display='none'"></video></div>`;
}
const width = w ? ` width="${w}"` : '';
return `<div class="chat-img"><img src="${encodeURI(file)}"${width} loading="lazy" onerror="this.style.display='none'"></div>`;
});
return result;
}
function renderQuoteReply(part: QuoteReply): string {
let html = '';
html += `<div>${renderPlainText(part.reply)}</div>`;
html += `<div class="chat-quote-bar">${escapeHtml(part.sender)}: ${renderPlainText(part.quote)}</div>`;
return html;
}
function renderMergeForward(part: MergeForward): string {
let cardHtml = '<div class="chat-forward-card">';
cardHtml += `<div class="forward-title">${escapeHtml(part.title)}</div>`;
const preview = part.items.slice(0, 3);
for (const item of preview) {
cardHtml += `<div class="forward-item">${escapeHtml(item)}</div>`;
}
if (part.items.length > 3) {
cardHtml += `<div class="forward-more">… 共 ${part.items.length} 条消息</div>`;
}
const uid = 'fw-' + Math.random().toString(36).slice(2, 8);
cardHtml += `<div class="forward-expand" onclick="document.getElementById('${uid}').classList.toggle('open')">查看全部聊天记录</div>`;
cardHtml += `<div class="forward-full" id="${uid}">`;
for (const item of part.items) {
cardHtml += `<div class="forward-item">${escapeHtml(item)}</div>`;
}
cardHtml += '</div></div>';
cardHtml += '<div class="forward-footer">聊天记录</div>';
return cardHtml;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function encodeURI(str: string): string {
return encodeURIComponent(str).replace(/%2F/g, '/');
}