mirror of
https://github.com/bruce2431/obsidian-chat-bubble-renderer.git
synced 2026-07-22 07:49:16 +00:00
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:
commit
bbdbbf0247
20 changed files with 6095 additions and 0 deletions
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
27
.github/workflows/lint.yml
vendored
Normal 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
50
.github/workflows/release.yml
vendored
Normal 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
22
.gitignore
vendored
Normal 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
1
.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
tag-version-prefix=""
|
||||
5
LICENSE
Normal file
5
LICENSE
Normal 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
127
README.md
Normal 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.ts(MarkdownPostProcessor 注册,检测 #聊天记录 标签)
|
||||
[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 全屏 overlay(position:fixed; z-index:10),不再替换 Obsidian DOM;sizer 用 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.body,z-index:100;Obsidian 底部任意重绘不影响气泡;Esc 键退出
|
||||
49
esbuild.config.mjs
Normal file
49
esbuild.config.mjs
Normal 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
33
eslint.config.mts
Normal 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
10
manifest.json
Normal 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
5022
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal 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
166
src/chat-parser.ts
Normal 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
118
src/chat-view.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function encodeURI(str: string): string {
|
||||
return encodeURIComponent(str).replace(/%2F/g, '/');
|
||||
}
|
||||
123
src/main.ts
Normal file
123
src/main.ts
Normal 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
39
src/settings.ts
Normal 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
227
styles.css
Normal 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
18
tsconfig.json
Normal 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
17
version-bump.mjs
Normal 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
3
versions.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"1.0.0": "1.0.0"
|
||||
}
|
||||
Loading…
Reference in a new issue