v1.0.0: Obsidian 聊天记录气泡渲染插件

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

11
.editorconfig Normal file
View file

@ -0,0 +1,11 @@
# top-most EditorConfig file
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
quote_type = single
tab_width = 4

27
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Node.js build
on:
push:
branches: ['**']
pull_request:
branches: ['**']
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run build --if-present
- run: npm run lint

50
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Release Obsidian plugin
on:
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
attestations: write
steps:
- uses: actions/checkout@v6
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: 'npm'
- name: Build plugin
run: |
npm ci
npm run build
- name: Check for optional styles
id: styles
run: |
[ -f styles.css ] && echo "exists=true" >> "$GITHUB_OUTPUT" || echo "exists=false" >> "$GITHUB_OUTPUT"
- name: Attest build provenance
uses: actions/attest@v4
with:
subject-path: |
main.js
${{ steps.styles.outputs.exists == 'true' && 'styles.css' || '' }}
- name: Create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--title="$tag" \
--draft \
main.js manifest.json ${{ steps.styles.outputs.exists == 'true' && 'styles.css' || '' }}

22
.gitignore vendored Normal file
View file

@ -0,0 +1,22 @@
# vscode
.vscode
# Intellij
*.iml
.idea
# npm
node_modules
# Don't include the compiled main.js file in the repo.
# They should be uploaded to GitHub releases instead.
main.js
# Exclude sourcemaps
*.map
# obsidian
data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store

1
.npmrc Normal file
View file

@ -0,0 +1 @@
tag-version-prefix=""

5
LICENSE Normal file
View file

@ -0,0 +1,5 @@
Copyright (C) 2020-2025 by Dynalist Inc.
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

127
README.md Normal file
View file

@ -0,0 +1,127 @@
---
创建日期: 2026-06-15T16:40:00
最后一次修改: 2026-06-15T16:44:00
---
# Pj4-聊天记录气泡渲染插件
**类型**Obsidian 社区插件
**功能**:将带有 `#聊天记录` 标签的 Markdown 文件自动渲染为微信风格的气泡对话框格式
**灵感来源**Pj1-李京瑾行为模拟/SubPj4-时间线工具v3/v4 中实现了微信风格渲染)
**项目状态**v1.0.0 可用
## 项目结构
```
Pj4-聊天记录气泡渲染插件/
├── README.md # 本文件
├── src/ # 插件源码
│ ├── main.ts # 插件入口 — post-processor 注册
│ ├── chat-parser.ts # 聊天记录解析器YAML跳过、header、quote-reply、merge-forward
│ ├── chat-view.ts # 气泡对话框渲染HTML生成、Obsidian 内部链接转换)
│ └── settings.ts # 设置界面(自定义"自己"标识名)
├── manifest.json # Obsidian 插件清单
├── package.json # 依赖管理
├── esbuild.config.mjs # 构建配置
├── styles.css # 微信风格气泡样式(深色/浅色主题自适应)
├── tsconfig.json # TypeScript 配置
├── version-bump.mjs # 版本号递增脚本
└── versions.json # 版本兼容性映射
```
## 功能
1. **识别**:自动检测 YAML frontmatter 中 `tags` 包含 `聊天记录``chat` 的文件
2. **解析**
- 标准消息头 `[发送者] YYYY-MM-DD HH:MM:SS`
- 引用回复 `> 发送者(wxid_xxx) MM-DD HH:MM`
- 合并转发卡片 `[合并转发|标题]`
- Obsidian 内部链接 `![[文件名.ext]]`(图片/音频/视频)
3. **渲染**
- 对方消息靠左(灰底),自己消息靠右(主题色)
- 引用回复带灰色引用条
- 合并转发卡片:预览 3 条 + 点击展开全文
- 图片/音频/视频内嵌播放
4. **自适应**:自动跟随 Obsidian 的深色/浅色主题
## 聊天记录格式
预期的 Markdown 输入格式:
```markdown
---
tags:
- 类别/聊天记录
---
## 2026-06-15
[bruceMTY] 2026-06-15 08:00:00
早上好
[李京瑾] 2026-06-15 08:01:00
早!今天有什么安排?
[李京瑾] 2026-06-15 08:02:00
> bruceMTY(wxid_xxx) 06-15 08:00
> 早上好
> > [引用]
刚醒,还没想好
[bruceMTY] 2026-06-15 08:05:00
[合并转发|聊天记录]
bruceMTY 2026-06-15 08:03
发了个表情包
李京瑾 2026-06-15 08:03
哈哈笑死
李京瑾 2026-06-15 08:04
这是哪部番
```
## 聊天记录渲染
聊天记录将自动渲染为微信风格气泡对话框:
- 对方(左):灰色气泡 + 左下圆角
- 自己(右):主题色气泡 + 右下圆角
- 引用回复:灰色引用条
- 合并转发:卡片预览+展开
## 安装方法
1. 构建:`npm run build`(生成 `main.js` + `styles.css`
2. 复制 `main.js`、`styles.css`、`manifest.json` 到 vault 的 `.obsidian/plugins/chat-bubble-renderer/`
3. 在 Obsidian 设置中启用插件
4. 在设置中配置"自己"的标识名称(默认为 bruceMTY, 我, me, 自己)
## 开发
```bash
npm install # 安装依赖
npm run dev # 开发模式watch
npm run build # 构建生产版本
npm run lint # ESLint 检查
```
# LOG
[2026-06-15-16:40]: 项目创建,从 obsidianmd/obsidian-sample-plugin 复制模板
[2026-06-15-16:42]: 完成 chat-parser.ts解析 YAML 跳过、消息头、引用回复、合并转发)
[2026-06-15-16:43]: 完成 chat-view.ts气泡渲染、Obsidian 内部链接转换、HTML 转义)
[2026-06-15-16:43]: 完成 main.tsMarkdownPostProcessor 注册,检测 #聊天记录 标签)
[2026-06-15-16:43]: 完成 styles.css微信风格气泡样式、深浅主题自适应
[2026-06-15-16:44]: 完成 settings.ts自定义"自己"标识名)
[2026-06-15-16:44]: 构建成功tsc ✅ → esbuild ✅v1.0.0 就绪
[2026-06-15-16:45]: 修复:砍掉 vaultBasePath 参数renderChatLog 简化为单参数
[2026-06-15-17:05]: 重构:从 post-processor 改为 Ctrl+P 命令手动触发渲染,避免 Obsidian section 分块触发问题
[2026-06-15-17:10]: 修复:用 view.data 替代 cachedRead避免中文路径 ENOENT 错误
[2026-06-15-17:15]: 样式修复:对方气泡改为 #f2f3f5(微信灰),自己气泡改为 #95ec69(微信绿+黑字)
[2026-06-15-17:20]: 滚动修复:改替换 .markdown-preview-sizer保留滚动容器结构force overflow-y
[2026-06-15-17:20]: 可用Ctrl+P → 渲染为聊天气泡,在阅读视图下工作正常
[2026-06-15-17:30]: 滚动持久化修复:改为独立 fixed 全屏 overlayposition:fixed; z-index:10不再替换 Obsidian DOMsizer 用 height:0 隐藏而非 display:none避免内部 "content div not found" 警告)
[2026-06-15-17:30]: 自适应界面:全屏铺满,气泡居中 max-width:800px底部留白 80px
[2026-06-15-17:30]: 退出机制:右上角 ✕ 按钮 + Ctrl+P「退出聊天气泡」命令 + onunload 自动清理
[2026-06-15-17:35]: 重构:零触碰 Obsidian DOM不隐藏 sizer纯 fixed overlay 挂在 document.bodyz-index:100Obsidian 底部任意重绘不影响气泡Esc 键退出

49
esbuild.config.mjs Normal file
View file

@ -0,0 +1,49 @@
import esbuild from 'esbuild';
import process from 'process';
import { builtinModules } from 'node:module';
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
if you want to view the source, please visit the github repository of this plugin
*/
`;
const prod = process.argv[2] === 'production';
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ['src/main.ts'],
bundle: true,
external: [
'obsidian',
'electron',
'@codemirror/autocomplete',
'@codemirror/collab',
'@codemirror/commands',
'@codemirror/language',
'@codemirror/lint',
'@codemirror/search',
'@codemirror/state',
'@codemirror/view',
'@lezer/common',
'@lezer/highlight',
'@lezer/lr',
...builtinModules,
],
format: 'cjs',
target: 'es2021',
logLevel: 'info',
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
minify: prod,
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}

33
eslint.config.mts Normal file
View file

@ -0,0 +1,33 @@
import tseslint from 'typescript-eslint';
import obsidianmd from 'eslint-plugin-obsidianmd';
import globals from 'globals';
import { globalIgnores } from 'eslint/config';
export default tseslint.config(
globalIgnores([
'node_modules',
'dist',
'esbuild.config.mjs',
'version-bump.mjs',
'versions.json',
'main.js',
'package.json',
'package-lock.json',
'tsconfig.json',
]),
{
languageOptions: {
globals: {
...globals.browser,
},
parserOptions: {
projectService: {
allowDefaultProject: ['eslint.config.mts', 'manifest.json'],
},
tsconfigRootDir: import.meta.dirname,
extraFileExtensions: ['.json'],
},
},
},
...obsidianmd.configs.recommended,
);

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "chat-bubble-renderer",
"name": "Chat Bubble Renderer",
"version": "1.0.0",
"minAppVersion": "1.0.0",
"description": "将带有 #聊天记录 标签的 Markdown 文件自动渲染为微信风格气泡对话框",
"author": "bruceMTY",
"authorUrl": "",
"isDesktopOnly": false
}

5022
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "chat-bubble-renderer",
"version": "1.0.0",
"description": "将带有 #聊天记录 标签的 Markdown 文件自动渲染为微信风格气泡对话框",
"main": "main.js",
"type": "module",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"version": "node version-bump.mjs && git add manifest.json versions.json",
"lint": "eslint ."
},
"keywords": ["obsidian", "plugin", "chat", "bubble", "wechat"],
"license": "MIT",
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^22.15.17",
"esbuild": "0.25.5",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.3.0",
"globals": "^17.6.0",
"jiti": "^2.6.1",
"obsidian": "latest",
"typescript": "^5.8.3",
"typescript-eslint": "^8.59.1"
}
}

166
src/chat-parser.ts Normal file
View file

@ -0,0 +1,166 @@
/**
* Chat Parser - # Markdown
*
*
* ---
* tags:
* - /
* ---
*
* [] YYYY-MM-DD HH:MM:SS
*
*
* > (wxid_xxx) MM-DD HH:MM
* >
* > > []
*
*
* [|]
* YYYY-MM-DD HH:MM
*
*/
export interface ChatMessage {
name: string;
time: string;
body: (string | QuoteReply | MergeForward)[];
}
export interface QuoteReply {
type: 'quote-reply';
sender: string;
quote: string;
reply: string;
}
export interface MergeForward {
type: 'merge-forward';
title: string;
items: string[];
}
export interface ParseResult {
preamble: string;
messages: ChatMessage[];
}
const HEADER_RE = /^\[(.+?)\]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/;
const QUOTE_REPLY_RE = /^>\s+(.+?)\(wxid_[^)]+\)\s+\d{2}-\d{2}\s+\d{2}:\d{2}(?::\d{2})?\s*$/;
const QUOTE_CONTENT_RE = /^>\s+(.+)/;
const QUOTE_REF_RE = /^>\s*>\s*\[引用\]/;
const MERGE_FORWARD_RE = /^\[合并转发\|(.+?)\]/;
const INDENT_CONTENT_RE = /^\s{2,}(.+)/;
export function parseChatLog(markdown: string): ParseResult {
const lines = markdown.split('\n');
const preamble: string[] = [];
const messages: ChatMessage[] = [];
let currentMsg: ChatMessage | null = null;
// Skip YAML frontmatter
let startIdx = 0;
if (lines.length > 0 && lines[0].trim() === '---') {
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
startIdx = i + 1;
break;
}
}
}
for (let i = startIdx; i < lines.length; i++) {
const line = lines[i];
const m = line.match(HEADER_RE);
if (m) {
// New message header
if (currentMsg) messages.push(currentMsg);
currentMsg = { name: m[1], time: m[2], body: [] };
const rest = line.slice(m[0].length).trim();
if (rest) currentMsg.body.push(rest);
} else if (currentMsg) {
const trimmed = line.trim();
// ── Merge forward card ──
const forwardMatch = line.match(MERGE_FORWARD_RE);
if (forwardMatch) {
const title = forwardMatch[1];
const cardLines: string[] = [];
let j = i + 1;
while (j < lines.length) {
const next = lines[j];
if (HEADER_RE.test(next) || next.trim() === '') {
if (next.trim() === '') { j++; continue; }
break;
}
const indentMatch = next.match(INDENT_CONTENT_RE);
if (indentMatch) {
cardLines.push(indentMatch[1]);
}
j++;
}
// Merge sender lines with following content
const mergedItems: string[] = [];
const senderRe = /^(.+?)\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/;
for (let k = 0; k < cardLines.length; k++) {
if (senderRe.test(cardLines[k]) && k + 1 < cardLines.length && !senderRe.test(cardLines[k + 1])) {
const senderName = cardLines[k].match(senderRe)![1];
mergedItems.push(senderName + ': ' + cardLines[k + 1]);
k++;
} else {
mergedItems.push(cardLines[k]);
}
}
currentMsg.body.push({ type: 'merge-forward', title, items: mergedItems });
i = j - 1;
continue;
}
// ── Quote reply ──
if (QUOTE_REPLY_RE.test(line)) {
const senderMatch = line.match(QUOTE_REPLY_RE)!;
const sender = senderMatch[1];
let quoteContent = '';
let j = i + 1;
while (j < lines.length) {
const nl = lines[j].trim();
if (QUOTE_REF_RE.test(nl)) { j++; continue; }
if (nl.startsWith('> ')) {
const contentMatch = nl.match(QUOTE_CONTENT_RE);
if (contentMatch) {
if (quoteContent) quoteContent += '\n';
quoteContent += contentMatch[1];
}
j++;
} else {
break;
}
}
let replyContent = '';
while (j < lines.length) {
const nl = lines[j].trim();
if (!nl || HEADER_RE.test(nl)) break;
if (replyContent) replyContent += '\n';
replyContent += nl;
j++;
}
currentMsg.body.push({ type: 'quote-reply', sender, quote: quoteContent, reply: replyContent });
i = j - 1;
continue;
}
// ── Plain content ──
if (trimmed) {
currentMsg.body.push(line);
} else if (currentMsg.body.length > 0) {
currentMsg.body.push('');
}
} else {
preamble.push(line);
}
}
if (currentMsg) messages.push(currentMsg);
const preambleText = preamble.join('\n').trim();
return { preamble: preambleText, messages };
}

118
src/chat-view.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* 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, '/');
}

123
src/main.ts Normal file
View file

@ -0,0 +1,123 @@
import {
Plugin,
TFile,
MarkdownView,
Notice,
} from 'obsidian';
import { DEFAULT_SETTINGS, ChatBubbleSettings, ChatBubbleSettingTab } from './settings';
import { renderChatLog } from './chat-view';
export default class ChatBubblePlugin extends Plugin {
settings!: ChatBubbleSettings;
async onload() {
await this.loadSettings();
this.addCommand({
id: 'render-chat-bubbles',
name: '渲染为聊天气泡',
callback: () => this.renderCurrentView(),
});
this.addCommand({
id: 'close-chat-bubbles',
name: '退出聊天气泡',
callback: () => this.closeBubbles(),
});
// Close overlay on Esc
this.registerDomEvent(document, 'keydown', (evt: KeyboardEvent) => {
if (evt.key === 'Escape') this.closeBubbles();
});
this.addSettingTab(new ChatBubbleSettingTab(this.app, this));
}
renderCurrentView() {
const view = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!view) {
new Notice('请在 Markdown 文件中使用');
return;
}
const file = view.file;
if (!(file instanceof TFile)) return;
const cache = this.app.metadataCache.getFileCache(file);
const tags = cache?.frontmatter?.tags;
if (!tags) {
new Notice('此文件没有 #聊天记录 标签');
return;
}
const tagArray = Array.isArray(tags) ? tags : [tags];
const hasChatTag = tagArray.some((t: string) => t.includes('聊天记录'));
if (!hasChatTag) {
new Notice('此文件没有 #聊天记录 标签');
return;
}
if (view.getMode() !== 'preview') {
new Notice('请先切换到阅读视图Ctrl+E');
return;
}
const content = view.data;
if (!content) {
new Notice('无法读取文件内容');
return;
}
const chatHtml = renderChatLog(content);
// Remove any previous overlay
this.closeBubbles();
// Fullscreen overlay — DO NOT touch Obsidian's DOM
const overlay = document.body.createDiv('chat-bubble-overlay');
overlay.style.cssText =
'position:fixed; top:0; left:0; right:0; bottom:0; z-index:100; ' +
'overflow-y:auto; box-sizing:border-box; ' +
'background:var(--background-primary);';
// Close button
const closeBtn = overlay.createDiv('chat-bubble-close');
closeBtn.innerHTML = '\u2715';
closeBtn.style.cssText =
'position:fixed; top:16px; right:20px; z-index:101; ' +
'font-size:22px; color:var(--text-muted); cursor:pointer; ' +
'width:36px; height:36px; display:flex; align-items:center; justify-content:center; ' +
'border-radius:50%; transition:background .15s;';
closeBtn.onmouseenter = () => closeBtn.style.background = 'var(--background-modifier-hover)';
closeBtn.onmouseleave = () => closeBtn.style.background = '';
closeBtn.onclick = () => this.closeBubbles();
// Chat content
const contentEl = overlay.createDiv('chat-bubble-content');
contentEl.innerHTML = chatHtml;
contentEl.style.cssText =
'max-width:800px; margin:0 auto; padding:40px 20px 80px;';
new Notice('聊天气泡已开启 | Esc 关闭');
}
closeBubbles() {
document.querySelectorAll('.chat-bubble-overlay').forEach(el => el.remove());
}
onunload() {
this.closeBubbles();
}
async loadSettings() {
this.settings = Object.assign(
{},
DEFAULT_SETTINGS,
(await this.loadData()) as Partial<ChatBubbleSettings>,
);
}
async saveSettings() {
await this.saveData(this.settings);
}
}

39
src/settings.ts Normal file
View file

@ -0,0 +1,39 @@
import { App, PluginSettingTab, Setting } from 'obsidian';
import ChatBubblePlugin from './main';
export interface ChatBubbleSettings {
selfNames: string[];
}
export const DEFAULT_SETTINGS: ChatBubbleSettings = {
selfNames: ['bruceMTY', '我', 'me', '自己'],
};
export class ChatBubbleSettingTab extends PluginSettingTab {
plugin: ChatBubblePlugin;
constructor(app: App, plugin: ChatBubblePlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl('h2', { text: 'Chat Bubble Renderer Settings' });
new Setting(containerEl)
.setName('Self identifiers')
.setDesc('Comma-separated list of names that identify "yourself" (left-aligned vs right-aligned bubbles)')
.addText((text) =>
text
.setPlaceholder('bruceMTY, 我, me, 自己')
.setValue(this.plugin.settings.selfNames.join(', '))
.onChange(async (value) => {
this.plugin.settings.selfNames = value.split(',').map(s => s.trim()).filter(s => s);
await this.plugin.saveSettings();
}),
);
}
}

227
styles.css Normal file
View file

@ -0,0 +1,227 @@
/*
* Chat Bubble Renderer - 微信风格气泡对话框样式
*
* 适用于 Obsidian 中带有 #聊天记录 标签的 Markdown 文件
*/
/* ── 容器 ── */
.chat-bubble-container {
padding: 16px 0;
}
/* ── 前言(标签/注释) ── */
.chat-preamble {
margin-bottom: 16px;
font-size: 13px;
line-height: 1.7;
color: var(--text-muted);
}
/* ── 消息列表容器 ── */
.chat-container {
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── 单条消息 ── */
.chat-msg {
display: flex;
flex-direction: column;
max-width: 85%;
}
.chat-msg.other {
align-self: flex-start;
}
.chat-msg.self {
align-self: flex-end;
}
/* ── 消息元信息(发送者 + 时间) ── */
.chat-msg .chat-meta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 3px;
padding: 0 12px;
font-weight: 500;
}
.chat-msg.self .chat-meta {
text-align: right;
}
/* ── 气泡本体 ── */
.chat-msg .chat-bubble {
padding: 8px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.65;
color: var(--text-normal);
word-break: break-word;
position: relative;
}
/* 对方气泡(左) */
.chat-msg.other .chat-bubble {
background: #f2f3f5;
border-bottom-left-radius: 4px;
}
/* 自己气泡(右) */
.chat-msg.self .chat-bubble {
background: #95ec69;
border-bottom-right-radius: 4px;
color: #000;
}
/* ── 气泡内元素 ── */
.chat-msg .chat-bubble p {
margin: 2px 0;
}
.chat-msg .chat-bubble img {
max-width: 120px;
max-height: 120px;
border-radius: 6px;
vertical-align: middle;
}
.chat-msg .chat-bubble code {
background: rgba(255, 255, 255, 0.1);
padding: 1px 5px;
border-radius: 4px;
font-size: 12px;
font-family: var(--font-monospace);
}
/* ── 图片消息 ── */
.chat-msg .chat-img {
margin-top: 4px;
}
.chat-msg .chat-img img {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
display: block;
object-fit: cover;
cursor: pointer;
transition: transform 0.15s ease;
}
.chat-msg .chat-img img:hover {
transform: scale(1.03);
}
/* ── 媒体消息(音频/视频) ── */
.chat-msg .chat-media {
margin-top: 4px;
}
.chat-msg .chat-media audio,
.chat-msg .chat-media video {
border-radius: 8px;
max-width: 100%;
}
/* ── 引用回复(微信风格灰色引用条) ── */
.chat-quote-bar {
margin-top: 4px;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.05);
border-left: 3px solid var(--text-muted);
border-radius: 4px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.chat-msg.self .chat-quote-bar {
background: rgba(0, 0, 0, 0.1);
color: rgba(255, 255, 255, 0.8);
border-left-color: rgba(255, 255, 255, 0.4);
}
/* ── 合并转发卡片 ── */
.chat-forward-card {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.chat-forward-card:hover {
background: var(--background-secondary);
}
.chat-forward-card .forward-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
color: var(--text-normal);
}
.chat-forward-card .forward-item {
font-size: 12px;
color: var(--text-muted);
padding: 3px 0;
border-bottom: 1px solid var(--background-modifier-border);
line-height: 1.5;
}
.chat-forward-card .forward-item:last-child {
border-bottom: none;
}
.chat-forward-card .forward-more {
font-size: 11px;
color: var(--text-accent);
padding: 4px 0;
text-align: center;
}
.chat-forward-card .forward-expand {
font-size: 12px;
color: var(--text-accent);
padding: 6px 0 2px;
text-align: center;
cursor: pointer;
border-top: 1px solid var(--background-modifier-border);
margin-top: 6px;
transition: opacity 0.15s;
}
.chat-forward-card .forward-expand:hover {
opacity: 0.8;
}
.chat-forward-card .forward-full {
display: none;
margin-top: 8px;
border-top: 1px solid var(--background-modifier-border);
padding-top: 8px;
}
.chat-forward-card .forward-full.open {
display: block;
}
.forward-footer {
font-size: 10px;
color: var(--text-muted);
text-align: center;
padding: 4px 0 2px;
opacity: 0.6;
}
/* ── 通配适配:在阅读视图中禁用默认 Markdown 渲染 ── */
.chat-bubble-container h1,
.chat-bubble-container h2,
.chat-bubble-container h3,
.chat-bubble-container h4 {
display: none;
}

18
tsconfig.json Normal file
View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES2021",
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"isolatedModules": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowSyntheticDefaultImports": true,
"lib": ["ES2021", "DOM"]
},
"include": ["src/**/*.ts"]
}

17
version-bump.mjs Normal file
View file

@ -0,0 +1,17 @@
import { readFileSync, writeFileSync } from 'fs';
const targetVersion = process.env.npm_package_version;
// read minAppVersion from manifest.json and bump version to target version
const manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));
// update versions.json with target version and minAppVersion from manifest.json
// but only if the target version is not already in versions.json
const versions = JSON.parse(readFileSync('versions.json', 'utf8'));
if (!(targetVersion in versions)) {
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));
}

3
versions.json Normal file
View file

@ -0,0 +1,3 @@
{
"1.0.0": "1.0.0"
}