mirror of
https://github.com/bruce2431/obsidian-chat-bubble-renderer.git
synced 2026-07-22 07:49:16 +00:00
v2.0.0: code refinements + English README rewrite
This commit is contained in:
parent
085a54557f
commit
d6ca8b689d
12 changed files with 145 additions and 50 deletions
161
README.md
161
README.md
|
|
@ -1,35 +1,59 @@
|
|||
# Chat Bubble Renderer
|
||||
|
||||
> Render Markdown chat logs as WeChat-style bubble dialogs directly in Obsidian.
|
||||
> 将聊天记录渲染为微信风格气泡对话框。
|
||||
> Instantly transform Markdown chat logs into WeChat-style bubble dialogs inside Obsidian.
|
||||
|
||||
[](https://github.com/bruce2431/obsidian-chat-bubble-renderer/releases)
|
||||
[](LICENSE)
|
||||
|
||||
## Features
|
||||
|
||||
- **Auto-render** — Switch to reading view on tagged files, bubbles appear automatically. `Ctrl+P` manual trigger also available.
|
||||
- **Parse** — Standard message headers `[Sender] YYYY-MM-DD HH:MM:SS`, quoted replies, merge-forward cards, system messages (nudge/recall), and Obsidian internal links `![[file.ext]]`
|
||||
- **Render** — WeChat-style bubbles: others left (gray), yourself right (green); quoted replies with gray bar and media icons; merge-forward cards with click-to-expand bubble view (sender+timestamp, self/other alignment, inline media); images/audio/video inline with click-to-preview
|
||||
- **File attachments** — PDF/DOC/XLS etc. rendered as file cards (name + type icon); PDFs open preview modal on click; file cards in merge-forward with send-time alignment
|
||||
- **Media preview** — Click any image or video to view full-size in dark overlay modal (close by clicking background)
|
||||
- **Performance** — Chunked base64 encoding, shared vault-name lookup, event-driven auto-render (no polling)
|
||||
- **Theme-aware** — Follows Obsidian's dark/light theme automatically
|
||||
### Automatic Rendering
|
||||
|
||||
## Latest Release
|
||||
Switch to reading view on any file tagged with `#聊天记录` — bubbles appear automatically. No manual trigger needed. Press `Esc` to dismiss. A `Ctrl+P` command ("Render as Chat Bubbles") is also available for manual control.
|
||||
|
||||
### 1.3.1
|
||||
### Message Types
|
||||
|
||||
- Reduced duplicate rendering logic for media/PDF overlays, platform badges, and merge-forward media summaries.
|
||||
- Hardened file-name decoding so unusual attachment names do not interrupt rendering.
|
||||
- Aligned package, manifest, and Obsidian version metadata for release publishing.
|
||||
- Updated the build config to resolve entry/output paths from the project root reliably.
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| **Text** | Standard messages in WeChat-style bubbles: others on the left (gray `#f2f3f5`), yourself on the right (green `#95ec69`). Bubbles auto-size to content width. |
|
||||
| **Quote Reply** | `>[Sender] quoted content` — renders a gray reference bar above the reply bubble. Supports quoted images (thumbnail), videos (first-frame preview), audio (inline toggle), PDFs (red badge), and location/card/link previews. |
|
||||
| **Merge-Forward** | `[合并转发\|title]` — a compact card showing title + 4-line preview + "Chat Records" footer. Click to open a navigable overlay with sender/timestamp bubbles, inline media, and support for **unlimited nesting** (click nested cards to dive deeper, click background to pop back). |
|
||||
| **System Messages** | Nudge, recall, group creation/rename, friend verification — rendered as centered gray tips without bubbles. |
|
||||
|
||||
## Usage
|
||||
### Rich Cards
|
||||
|
||||
Tag your Markdown file with `tags: [聊天记录]`, then switch to **reading view** (`Ctrl+E`). Bubbles render automatically. `Esc` to exit.
|
||||
| Card | Format | Behavior |
|
||||
|------|--------|----------|
|
||||
| **Location** | `[位置\|address\|city\|lng,lat]` | MapLibre vector map preview with red pin marker. Click to open in OpenStreetMap. |
|
||||
| **Contact** | `[名片\|nickname\|wechat_id\|sex\|region]` | Avatar (from `![[image]]` on next line) + nickname + tags. Personal 名片 shown as 👤, official accounts as 📢. |
|
||||
| **Link** | `[链接\|title\|cover_url\|description](url)` | Rich preview card with platform badge (Bilibili, NetEase Music, Xiaohongshu, WeChat, QQ, LOFTER, Tencent Meeting — auto-detected by domain) + cover thumbnail on the right. Simple cards fall back to a 🔗 icon. Supports `[小程序\|...]` variant. |
|
||||
| **File** | `![[file.pdf]]` | Card with filename + extension badge (colored red). PDFs open an iframe preview on click. Supports PDF/DOC/DOCX/XLS/XLSX/PPT/PPTX/TXT/ZIP/RAR/7Z. |
|
||||
|
||||
### Chat Log Format
|
||||
### Media Support
|
||||
|
||||
- **Images** — Inline rendering with `loading="lazy"`. Click to preview full-size in a dark overlay modal.
|
||||
- **Videos** — Static preview card (200×280 + first-frame thumbnail + ▶ overlay). Click to play full-screen.
|
||||
- **Audio** — WeChat-style voice bubble (🔊 icon + dynamic duration). Click to play/pause inline.
|
||||
- All media resolved via Obsidian's vault resource paths — audio files are base64-encoded for playback.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Virtual scrolling** — Only ~150 DOM nodes visible at any time, even with tens of thousands of messages.
|
||||
- **Lazy media resolution** — Media file URIs are resolved on-demand as messages scroll into view.
|
||||
- **Chunked base64 encoding** — Large audio files encoded in 4096-byte chunks, ~40× faster.
|
||||
- **Event-driven auto-render** — No polling; renders on tab switch and layout change via workspace events.
|
||||
- **Shared name map** — Vault file lookups cached once and kept in sync via create/delete/rename events.
|
||||
|
||||
### Theme & UX
|
||||
|
||||
- Follows Obsidian's light/dark theme automatically via CSS custom properties.
|
||||
- All interactive elements use event delegation (no inline `onclick`) — clean, secure, maintainable.
|
||||
- Overlay modals close on background click or `Esc` key.
|
||||
- `safeHrefAttr` protocol filtering on all user-generated links (`http`/`https`/`mailto`/`obsidian` only).
|
||||
|
||||
## Chat Log Format
|
||||
|
||||
### Basic Message
|
||||
|
||||
```markdown
|
||||
---
|
||||
|
|
@ -37,35 +61,102 @@ tags:
|
|||
- 聊天记录
|
||||
---
|
||||
|
||||
[自己] 2026-06-15 08:00:00
|
||||
我喜欢你
|
||||
|
||||
[对方] 2026-06-18 08:01:52
|
||||
> [自己] 我喜欢你
|
||||
我不喜欢你
|
||||
|
||||
[自己] 2026-06-18 11:16:02
|
||||
![[哭哭_emoj.jpg]]
|
||||
|
||||
[对方] 2026-06-19 17:34:39
|
||||
![[文件.pdf]]
|
||||
[Sender Name] 2026-06-30 10:13:49
|
||||
Message content here.
|
||||
[Reciver Name] 2026-06-30 10:13:49
|
||||
Message content here.
|
||||
```
|
||||
|
||||

|
||||

|
||||
|
||||
Supported formats: quote replies (`>`), merge-forward (`[合并转发|title]`), system messages (nudge/recall), media embeds (`![[file.ext]]`), file attachments (PDF/DOC/XLS).
|
||||
### Quote Reply
|
||||
|
||||
```markdown
|
||||
[甲] 2026-06-30 10:12:21
|
||||
> [甲] Quoted message text
|
||||
My reply content.
|
||||
```
|
||||
|
||||

|
||||
|
||||
Quotes also support media and card references:
|
||||
```markdown
|
||||
> [甲] ![[image.jpg]] — thumbnail preview
|
||||
> [甲] [位置|Shanghai|上海|121.47,31.23] — map thumbnail
|
||||
> [甲] [名片|张三|wxid_xxx|男|北京] — contact card preview
|
||||
> [甲] [链接|Title|cover.jpg](https://...) — link card preview
|
||||
```
|
||||
|
||||
### Rich Cards
|
||||
|
||||
```markdown
|
||||
[甲] 2026-06-30 10:14:07
|
||||
[位置|详细地址|市|经度,纬度]
|
||||
|
||||
[甲] 2026-06-30 10:14:26
|
||||
[名片|Name|微信号:wxid|Gender|Location]
|
||||
![[avatar.jpg]]
|
||||
|
||||
[甲] 2026-06-30 10:14:55
|
||||
[链接|标题|https://example.com/cover.jpg|其它信息](https://example.com/article)
|
||||
|
||||
[甲] 2026-06-30 10:16:23
|
||||
[合并转发|甲 和 丙的聊天记录]
|
||||
甲 2026-6-30 10:12
|
||||
你好
|
||||
丙 2026-6-30 10:12
|
||||
你好
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Media Embeds
|
||||
|
||||
```markdown
|
||||
[甲] 2026-06-30 10:13:49
|
||||
![[photo.jpg]]
|
||||
|
||||
[甲] 2026-06-30 10:14:00
|
||||
![[voice.amr]]
|
||||
|
||||
[甲] 2026-06-30 10:14:30
|
||||
![[video.mp4]]
|
||||
|
||||
[甲] 2026-06-30 10:15:00
|
||||
![[document.pdf]]
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
**Community Plugin Store**: Search "Chat Bubble Renderer" in Obsidian → Community plugins.
|
||||
### Community Plugin Store
|
||||
|
||||
**Manual**: `npm install && npm run build` → copy `main.js`, `styles.css`, `manifest.json` to `.obsidian/plugins/chat-bubble-renderer/`.
|
||||
Search **"Chat Bubble Renderer"** in Obsidian → Settings → Community plugins → Browse → Install.
|
||||
|
||||
### Manual
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bruce2431/obsidian-chat-bubble-renderer.git
|
||||
cd obsidian-chat-bubble-renderer
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then copy `main.js`, `styles.css`, and `manifest.json` into your vault's `.obsidian/plugins/chat-bubble-renderer/` directory.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Description | Default |
|
||||
|---------|-------------|---------|
|
||||
| Self Identifiers | Names that identify "you" in chat logs | 我, me, 自己 |
|
||||
| **Self Identifiers** | Comma-separated names that identify yourself (your messages render on the right, in green). Supports Chinese commas. | `我`, `me` |
|
||||
|
||||

|
||||
|
||||
## Requirements
|
||||
|
||||
- Obsidian ≥ **1.5.0**
|
||||
- Desktop or mobile (plugin is not desktop-only)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
BIN
e1.png
Normal file
BIN
e1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
e2.png
Normal file
BIN
e2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
BIN
e3.png
Normal file
BIN
e3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
e4.png
Normal file
BIN
e4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
exp.png
BIN
exp.png
Binary file not shown.
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 173 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "chat-bubble-renderer",
|
||||
"name": "Chat Bubble Renderer",
|
||||
"version": "1.3.1",
|
||||
"version": "2.0.0",
|
||||
"minAppVersion": "1.5.0",
|
||||
"description": "Render Markdown chat logs as WeChat-style bubble dialogs. 将聊天记录渲染为微信风格气泡对话框.",
|
||||
"author": "bruce2431",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "chat-bubble-renderer",
|
||||
"version": "1.3.1",
|
||||
"version": "2.0.0",
|
||||
"description": "将带有 #聊天记录 标签的 Markdown 文件自动渲染为微信风格气泡对话框",
|
||||
"main": "main.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export interface ParseResult {
|
|||
}
|
||||
|
||||
const HEADER_RE = /^\[(.*?)\]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})/;
|
||||
const QUOTE_REPLY_RE = /^>\s*\[(.+?)\]\s+(.+)/;
|
||||
export const QUOTE_REPLY_RE = /^>\s*\[(.+?)\]\s+(.+)/;
|
||||
const MERGE_FORWARD_RE = /^\[合并转发\|(.+?)\]/;
|
||||
const LINK_CARD_RE = /^\[(?:链接|小程序)\|([^\]]+)\]\((.+)\)$/;
|
||||
export { LINK_CARD_RE };
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
* 不在 HTML 中嵌入 onclick — 安全且可维护。
|
||||
*/
|
||||
|
||||
import { parseChatLog, MergeForward, LinkCard, LocationCard, Card, parseLinkString, parseLocationString, parseCardString, formatLocationAddress, buildGeoUrl, CARD_RE, QUOTE_CARD_RE } from './chat-parser';
|
||||
import { parseChatLog, MergeForward, LinkCard, LocationCard, Card, parseLinkString, parseLocationString, parseCardString, formatLocationAddress, buildGeoUrl, CARD_RE, QUOTE_CARD_RE, QUOTE_REPLY_RE } from './chat-parser';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import { AUDIO_EXTS, VIDEO_EXTS, IMAGE_EXTS, FILE_EXTS, FILE_EXT_RE } from './constants';
|
||||
|
||||
|
|
@ -325,7 +325,7 @@ function renderAudioBubble(text: string, side: string): string {
|
|||
const match = text.match(/!\[\[(.+?)\]\]/);
|
||||
const resolved = match?.[1] || '';
|
||||
const uri = resolved.startsWith('RESOLVED:') ? resolved.slice(9) : resolved;
|
||||
const uid = 'au-' + Math.random().toString(36).slice(2, 8);
|
||||
const uid = 'au-' + shortId();
|
||||
return `<div class="chat-audio-msg ${side}" data-action="toggle-audio" data-audio-id="${uid}" ><span class="chat-audio-icon">🔊</span><span class="chat-audio-text">语音消息</span><span class="chat-audio-dur"></span><audio id="${uid}" src="${uri}" hidden preload="metadata"></audio></div>`;
|
||||
}
|
||||
|
||||
|
|
@ -556,7 +556,7 @@ export function destroyLocationMaps(container: HTMLElement) {
|
|||
}
|
||||
|
||||
function isSelfMessage(name: string, selfNames?: string[]): boolean {
|
||||
const names = selfNames?.length ? selfNames : ['自己', '我', 'me'];
|
||||
const names = selfNames?.length ? selfNames : ['我', 'me'];
|
||||
return names.some(n => name.toLowerCase() === n.toLowerCase());
|
||||
}
|
||||
|
||||
|
|
@ -614,7 +614,7 @@ function renderQuoteBar(sender: string, quote: string, avatar?: string): string
|
|||
} else if (isVideo) {
|
||||
preview = `<video src="${escapeAttr(resolved)}" class="chat-quote-video-thumb" data-action="preview-media" data-type="video" data-uri="${escapeAttr(resolved)}" muted preload="metadata"></video>`;
|
||||
} else if (isAudio) {
|
||||
const uid = 'au-' + Math.random().toString(36).slice(2, 8);
|
||||
const uid = 'au-' + shortId();
|
||||
const label = resolved.startsWith('data:') ? '语音消息' : (safeDecodeURI(resolved.split('?')[0].split('/').pop() || 'audio'));
|
||||
preview = `<span class="chat-quote-audio-bar" data-action="toggle-audio" data-audio-id="${uid}" ><span class="chat-quote-audio-icon">🔊</span>${escapeHtml(label)}<audio id="${uid}" src="${escapeAttr(resolved)}" hidden preload="metadata"></audio></span>`;
|
||||
} else if (isFile) {
|
||||
|
|
@ -683,7 +683,7 @@ function renderQuoteBar(sender: string, quote: string, avatar?: string): string
|
|||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (VIDEO_EXTS.includes(ext)) return `<div class="chat-quote-bar"><span class="chat-quote-sender">${escapeHtml(sender)}</span><span class="chat-quote-video-icon">▶</span></div>`;
|
||||
if (AUDIO_EXTS.includes(ext)) {
|
||||
const uid = 'au-' + Math.random().toString(36).slice(2, 8);
|
||||
const uid = 'au-' + shortId();
|
||||
return `<div class="chat-quote-bar"><span class="chat-quote-sender">${escapeHtml(sender)}</span><span class="chat-quote-audio-bar" data-action="toggle-audio" data-audio-id="${uid}" ><span class="chat-quote-audio-icon">🔊</span>语音消息<audio id="${uid}" src="${escapeAttr(safeEncodeURI(filename))}" hidden preload="metadata"></audio></span></div>`;
|
||||
}
|
||||
if (IMAGE_EXTS.includes(ext)) return `<div class="chat-quote-bar"><span class="chat-quote-sender">${escapeHtml(sender)}</span>[图片]</div>`;
|
||||
|
|
@ -697,7 +697,7 @@ function renderQuoteBar(sender: string, quote: string, avatar?: string): string
|
|||
}
|
||||
|
||||
function renderMergeForward(part: MergeForward, metaMap: Map<string, FileMeta>, selfNames?: string[], nested = false): string {
|
||||
const uid = 'fw-' + Math.random().toString(36).slice(2, 8);
|
||||
const uid = 'fw-' + shortId();
|
||||
const cls = nested ? 'chat-forward-card nested' : 'chat-forward-card';
|
||||
let cardHtml = `<div class="${cls}" data-action="expand-forward" data-id="${uid}">`;
|
||||
cardHtml += `<div class="forward-title">${escapeHtml(part.title)}</div>`;
|
||||
|
|
@ -734,8 +734,7 @@ function renderForwardTemplate(
|
|||
const sr = classifyAndRender(content, metaMap);
|
||||
|
||||
// ── Quote reply ──
|
||||
const QUOTE_RE = /^>\s*\[(.+?)\]\s+(.+)/;
|
||||
const quoteMatch = content.match(QUOTE_RE);
|
||||
const quoteMatch = content.match(QUOTE_REPLY_RE);
|
||||
if (quoteMatch) {
|
||||
const qSender = quoteMatch[1];
|
||||
const qText = quoteMatch[2];
|
||||
|
|
@ -780,6 +779,8 @@ function renderForwardTemplate(
|
|||
// 工具函数
|
||||
// ────────────────────────────────────
|
||||
|
||||
const shortId = () => Math.random().toString(36).slice(2, 8);
|
||||
|
||||
function renderForwardPreview(part: MergeForward): string {
|
||||
const rows = part.items
|
||||
.filter((item): item is Exclude<typeof item, { plain: string }> => !('plain' in item))
|
||||
|
|
|
|||
|
|
@ -615,6 +615,8 @@
|
|||
/* ── 合并转发卡片 ── */
|
||||
.chat-forward-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
|
|
@ -635,7 +637,7 @@
|
|||
.chat-forward-card .forward-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-normal);
|
||||
overflow: hidden;
|
||||
|
|
@ -676,7 +678,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.forward-preview-line {
|
||||
|
|
@ -833,7 +835,7 @@
|
|||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-top: none;
|
||||
margin-top: 0;
|
||||
margin-top: auto;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,5 +16,6 @@
|
|||
"1.2.8": "1.5.0",
|
||||
"1.2.9": "1.5.0",
|
||||
"1.3.0": "1.5.0",
|
||||
"1.3.1": "1.5.0"
|
||||
"1.3.1": "1.5.0",
|
||||
"2.0.0": "1.5.0"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue