finch-markdown-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2353 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Markdown Editor</title>
7
+ <style>
8
+ :root{
9
+ --bg:var(--finch-bg-root,#f7f7f8);
10
+ --card:var(--finch-bg-elevated,#fff);
11
+ --text:var(--finch-text-primary,#1d1d20);
12
+ --muted:var(--finch-text-secondary,#777);
13
+ --border:var(--finch-border,#ddd);
14
+ --accent:var(--finch-accent,#3f6b56);
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root{
18
+ --bg:var(--finch-bg-root,#17171a);
19
+ --card:var(--finch-bg-elevated,#242429);
20
+ --text:var(--finch-text-primary,#eee);
21
+ --muted:var(--finch-text-secondary,#aaa);
22
+ --border:var(--finch-border,#3c3c43);
23
+ --accent:var(--finch-accent,#70a889);
24
+ }
25
+ }
26
+ *{box-sizing:border-box}
27
+ html,body{height:100%}
28
+ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-body,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif)}
29
+ .app{width:100%;height:100vh;display:flex;flex-direction:column}
30
+ .stage{position:relative;flex:1;min-height:0}
31
+ .pane{position:absolute;inset:0;display:none}
32
+ .pane.active{display:block}
33
+ .editor-wrap{position:relative;width:100%;height:100%;overflow:hidden;background:var(--card)}
34
+ #editor{width:100%;height:100%;overflow:hidden}
35
+ .fake-sel{position:fixed;z-index:15;border-radius:2px;background:rgba(63,107,86,.35);background:color-mix(in srgb,var(--accent) 34%,transparent);pointer-events:none}
36
+ .preview-shell{position:absolute;inset:0;padding:32px 20px 20px;background:var(--bg);overflow:hidden}
37
+ #article{display:block;width:100%;height:100%;border:0;border-radius:10px;background:var(--card)}
38
+ /* Home / empty state: a launch page rather than a placeholder — headline plus
39
+ the Markdown files this editor recently touched in the current working
40
+ directory, newest-modified first, so a session can be resumed in one click. */
41
+ .empty{position:absolute;z-index:5;inset:0;background:var(--card);color:var(--muted);display:block;overflow:auto;padding:30px 26px 34px;font-size:14px;line-height:1.7}
42
+ .empty[hidden]{display:none}
43
+ .home{max-width:860px;margin:0 auto}
44
+ .home-head{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:22px}
45
+ .home-head h1{margin:0 0 3px;font-size:19px;font-weight:650;line-height:1.3;color:var(--text);letter-spacing:-.01em}
46
+ .home-head p{margin:0;font-size:12.5px;line-height:1.5;color:var(--muted)}
47
+ .home-cta{display:flex;gap:8px;flex:none}
48
+ .home-btn{appearance:none;display:inline-flex;align-items:center;gap:5px;border:1px solid var(--accent);border-radius:8px;background:var(--accent);color:#fff;padding:7px 12px;font:inherit;font-size:13px;font-weight:600;cursor:pointer;white-space:nowrap}
49
+ .home-btn:hover{filter:brightness(1.06)}
50
+ .home-btn.ghost{background:transparent;border-color:var(--border);color:var(--text);font-weight:500}
51
+ .home-btn.ghost:hover{filter:none;background:var(--bg)}
52
+ .home-btn svg{width:14px;height:14px;flex:none}
53
+ .home-section{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin:0 0 10px;font-size:12px;color:var(--muted)}
54
+ .home-section b{font-size:12.5px;font-weight:600;color:var(--text);flex:none}
55
+ /* flex:0 1 auto (not flex:1) so the button hugs its own text — the blank
56
+ space next to a short path stays unclickable. Front-truncation (the "…"
57
+ prefix) happens in JS, not via a direction:rtl CSS trick: mixing a plain
58
+ "~" with rtl reordered the whole string visually ("finch-tools/~" instead
59
+ of "~/finch-tools"), so this box now renders left-to-right like normal. */
60
+ .home-section .home-cwd{flex:0 1 auto;max-width:100%;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.75;cursor:pointer;background:none;border:0;padding:0;font:inherit;color:inherit}
61
+ .home-section .home-cwd:hover{opacity:1;color:var(--accent);text-decoration:underline}
62
+ .home-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:12px}
63
+ .doc-card{position:relative;display:flex;flex-direction:column;height:138px;padding:0;text-align:left;border:1px solid var(--border);border-radius:11px;background:var(--bg);color:var(--text);font:inherit;cursor:pointer;overflow:hidden;transition:border-color .15s ease,box-shadow .15s ease,transform .1s ease}
64
+ .doc-card:hover{border-color:var(--accent);box-shadow:0 5px 16px rgba(0,0,0,.1)}
65
+ .doc-card:active{transform:scale(.99)}
66
+ .doc-preview{position:relative;flex:1;min-height:0;padding:11px 12px 0;font-size:11.5px;line-height:1.6;color:var(--muted);overflow:hidden;word-break:break-word}
67
+ .doc-preview::after{content:'';position:absolute;left:0;right:0;bottom:0;height:34px;background:linear-gradient(to bottom,transparent,var(--bg))}
68
+ .doc-foot{flex:none;padding:8px 12px 10px;border-top:1px solid var(--border);background:var(--card)}
69
+ .doc-title{font-size:13px;font-weight:600;line-height:1.35;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
70
+ .doc-sub{margin-top:2px;display:flex;gap:6px;font-size:11px;line-height:1.4;color:var(--muted)}
71
+ .doc-sub .doc-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
72
+ .doc-sub .doc-time{flex:none;opacity:.8}
73
+ .home-hint{margin:0;padding:22px 0;text-align:center;font-size:12.5px;color:var(--muted)}
74
+ .home-hint[hidden],.home-recent[hidden]{display:none}
75
+ .status{font-size:12px;color:var(--muted);padding:5px 10px;text-align:center;border-top:1px solid var(--border);background:var(--bg)}
76
+ .status.error{color:#c54232}
77
+ .status:empty{padding:0;border-top:0}
78
+ .popup{position:fixed;z-index:20;display:flex;align-items:center;gap:6px;background:var(--card);border:1px solid var(--border);border-radius:9px;box-shadow:0 8px 22px rgba(0,0,0,.22);padding:5px;transform-origin:center bottom;animation:popup-in .18s cubic-bezier(.2,.8,.2,1)}
79
+ .popup.hidden{display:none}
80
+ @keyframes popup-in{from{opacity:0;transform:translateY(5px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
81
+ @media (prefers-reduced-motion:reduce){.popup{animation:none}}
82
+ .popup input{width:147px;border:0;outline:0;background:transparent;color:var(--text);font:inherit;padding:4px 6px}
83
+ .popup button{font:inherit;border:0;background:var(--accent);color:#fff;border-radius:6px;padding:5px 10px;cursor:pointer;white-space:nowrap}
84
+ .popup button:hover{opacity:.9}
85
+ /* Unsaved-changes confirm, deliberately a plain in-page overlay rather than
86
+ `window.finch.ui.confirm()`: the only entry point that can trigger it is
87
+ the toolbar's "home" button, which reaches this page as a `finch:menu`
88
+ postMessage — not a real user gesture — so the same Bridge action that
89
+ requires one (see `.actions` comment above) could not be trusted to fire
90
+ reliably from there. A plain DOM overlay has no such gesture requirement. */
91
+ .confirm-overlay{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.32);animation:confirm-fade .12s ease}
92
+ .confirm-overlay[hidden]{display:none}
93
+ @keyframes confirm-fade{from{opacity:0}to{opacity:1}}
94
+ .confirm-card{width:300px;max-width:calc(100% - 40px);background:var(--card);border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 40px rgba(0,0,0,.3);padding:16px;animation:confirm-in .16s cubic-bezier(.2,.8,.2,1)}
95
+ @keyframes confirm-in{from{opacity:0;transform:translateY(6px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}}
96
+ @media (prefers-reduced-motion:reduce){.confirm-overlay,.confirm-card{animation:none}}
97
+ .confirm-card p{margin:0 0 16px;font-size:13px;line-height:1.6;color:var(--text)}
98
+ .confirm-actions{display:flex;justify-content:flex-end;gap:8px}
99
+ .confirm-actions button{font:inherit;font-size:13px;border:1px solid var(--border);background:transparent;color:var(--text);border-radius:7px;padding:6px 12px;cursor:pointer}
100
+ .confirm-actions button:hover{background:var(--bg)}
101
+ .confirm-actions button.primary{border-color:var(--accent);background:var(--accent);color:#fff}
102
+ .confirm-actions button.primary:hover{filter:brightness(1.06)}
103
+ .confirm-actions button.ghost{border-color:transparent}
104
+ /* Floating copy/export bar, deliberately living INSIDE this page rather than
105
+ in the host toolbar: a host toolbar click arrives here as a postMessage,
106
+ which is not a user activation for this document, so the async Clipboard
107
+ API and execCommand('copy') both refuse to run until the user happens to
108
+ click the page first. A real in-page click is a genuine user gesture, so
109
+ every copy/export below works on the first try. */
110
+ .actions{position:absolute;right:32px;bottom:32px;z-index:8;display:flex;align-items:center;gap:3px;padding:5px;border-radius:11px;background:var(--card);border:1px solid var(--border);box-shadow:0 6px 20px rgba(0,0,0,.18);transition:gap .15s ease}
111
+ .actions[hidden]{display:none}
112
+ .actions button{position:relative;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;border-radius:7px;background:transparent;color:var(--text);font:inherit;cursor:pointer;white-space:nowrap}
113
+ .actions button:hover:not(:disabled){background:var(--bg)}
114
+ .actions button[title]::after{content:attr(title);position:absolute;left:50%;bottom:calc(100% + 8px);z-index:20;padding:6px 8px;border:1px solid var(--border);border-radius:7px;background:linear-gradient(135deg,color-mix(in srgb,var(--card) 96%,#fff),var(--bg));box-shadow:0 5px 16px rgba(0,0,0,.2);color:var(--text);font-size:12px;line-height:1;white-space:nowrap;pointer-events:none;opacity:0;transform:translate(-50%,3px);transition:opacity .16s ease,transform .16s ease}
115
+ .actions button[title]:hover::after{opacity:1;transform:translate(-50%,0)}
116
+ /* Center tooltips over ordinary buttons; at either end of the floating bar,
117
+ grow them inward instead of letting a long label escape the panel. */
118
+ .actions button:first-child[title]::after{left:0;transform:translateY(3px)}
119
+ .actions button:first-child[title]:hover::after{transform:translateY(0)}
120
+ .actions button:last-child[title]::after{left:auto;right:0;transform:translateY(3px)}
121
+ .actions button:last-child[title]:hover::after{transform:translateY(0)}
122
+ .actions button:disabled{opacity:.45;cursor:default}
123
+ .actions .sep{width:1px;height:18px;background:var(--border);margin:0 3px}
124
+ .actions svg{width:16px;height:16px;flex:none}
125
+ .actions .act-toggle{padding:0 6px;color:var(--muted)}
126
+ .actions .act-toggle svg{transition:transform .15s ease;transform:rotate(180deg)}
127
+ /* Collapsed = only the WeChat-copy action (icon only, label hidden) plus the
128
+ toggle remain visible; every other button/separator is a one-off export
129
+ action that most sessions never touch, so hiding them by default keeps
130
+ the floating bar from covering preview content. */
131
+ .actions.collapsed [data-collapsible]{display:none}
132
+ .actions.collapsed #actCopyWx{padding:0 7px}
133
+ .actions.collapsed #actCopyWx .act-label{display:none}
134
+ .actions.collapsed .act-toggle svg{transform:rotate(0deg)}
135
+ </style>
136
+ </head>
137
+ <body>
138
+ <main class="app">
139
+ <div class="stage">
140
+ <div id="empty" class="empty">
141
+ <div class="home">
142
+ <header class="home-head">
143
+ <div>
144
+ <h1 data-i18n="home.title">Markdown Editor</h1>
145
+ <p id="homeTagline"></p>
146
+ </div>
147
+ <div class="home-cta">
148
+ <button id="emptyOpen" class="home-btn ghost" type="button">
149
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>
150
+ <span data-i18n="home.openFile">Open File</span>
151
+ </button>
152
+ <button id="emptyNew" class="home-btn" type="button">
153
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 18v-6"/><path d="M9 15h6"/></svg>
154
+ <span data-i18n="home.newDoc">New Document</span>
155
+ </button>
156
+ </div>
157
+ </header>
158
+ <section id="homeRecent" class="home-recent" hidden>
159
+ <div class="home-section"><b data-i18n="home.recentLabel">Recent</b><button id="homeCwd" type="button" class="home-cwd"></button></div>
160
+ <div id="homeGrid" class="home-grid"></div>
161
+ </section>
162
+ <p id="homeHint" class="home-hint" data-i18n="home.hintDefault">Open a Markdown file — recently edited documents will show up here.</p>
163
+ </div>
164
+ </div>
165
+ <div id="editPane" class="pane">
166
+ <div class="editor-wrap">
167
+ <div id="editor" data-i18n-aria="editor.ariaLabel" aria-label="Markdown editor"></div>
168
+ </div>
169
+ </div>
170
+ <div id="previewPane" class="pane">
171
+ <div class="preview-shell">
172
+ <iframe id="article" sandbox="allow-same-origin" data-i18n-title="meta.title" title="Article Preview"></iframe>
173
+ <div id="actions" class="actions" hidden>
174
+ <button id="actCopyWx" type="button" data-i18n-title="actions.copyToWx" title="Copy for WeChat">
175
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path stroke-linecap="round" d="M7 7h.009m5.982 0H13m4.991 7.5H18m-4 0h.009"/><path d="M10 16c0 2.761 2.686 5 6 5c.907 0 1.767-.168 2.538-.468c.189-.073.393-.1.592-.063L22 21l-.652-2.03a1.13 1.13 0 0 1 .11-.89A4.3 4.3 0 0 0 22 16c0-2.761-2.686-5-6-5s-6 2.239-6 5Z"/><path d="M17.873 11.249Q18 10.639 18 10c0-3.866-3.582-7-8-7s-8 3.134-8 7c0 1.112.297 2.164.824 3.098c.147.26.196.567.108.853L2 17l3.914-.76c.208-.041.422-.013.617.07a9 9 0 0 0 3.589.69"/></svg>
176
+ </button>
177
+ <span class="sep" data-collapsible></span>
178
+ <button id="actCopyImg" type="button" data-i18n-title="actions.copyImg" title="Copy image" data-collapsible>
179
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
180
+ </button>
181
+ <button id="actSaveImg" type="button" data-i18n-title="actions.exportImg" title="Export image (download)" data-collapsible>
182
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6.3"/><path d="m2 16 5-5c.928-.893 2.072-.893 3 0l3 3"/><path d="m14 14 1-1c.928-.893 2.072-.893 3 0"/><path d="M19 16v6"/><path d="m22 19-3 3-3-3"/><circle cx="9" cy="9" r="2"/></svg>
183
+ </button>
184
+ <button id="actSavePdf" type="button" data-i18n-title="actions.exportPdf" title="Export PDF" data-collapsible>
185
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M12 18v-6"/><path d="m9 15 3 3 3-3"/></svg>
186
+ </button>
187
+ <span class="sep" data-collapsible></span>
188
+ <button id="actAiStyle" type="button" data-i18n-title="actions.aiStyle" title="Ask AI to design layout" data-collapsible>
189
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.64 3.64-1.28-1.28a1.21 1.21 0 0 0-1.72 0L2.36 18.64a1.21 1.21 0 0 0 0 1.72l1.28 1.28a1.2 1.2 0 0 0 1.72 0L21.64 5.36a1.2 1.2 0 0 0 0-1.72"/><path d="m14 7 3 3"/><path d="M5 6v4"/><path d="M19 14v4"/><path d="M10 2v2"/><path d="M7 8H3"/><path d="M21 16h-4"/><path d="M11 3H9"/></svg>
190
+ </button>
191
+ <button id="actToggle" type="button" class="act-toggle" data-i18n-title="actions.toggle" title="Expand/collapse">
192
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
193
+ </button>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ <div id="status" class="status"></div>
199
+ </main>
200
+ <section id="popup" class="popup hidden">
201
+ <input id="popupInput" type="text">
202
+ <button id="popupSend" data-i18n="popup.send">Send</button>
203
+ </section>
204
+ <div id="confirmOverlay" class="confirm-overlay" hidden>
205
+ <div class="confirm-card">
206
+ <p id="confirmMessage"></p>
207
+ <div class="confirm-actions">
208
+ <button id="confirmCancel" class="ghost" data-i18n="confirm.cancel">Cancel</button>
209
+ <button id="confirmDiscard" data-i18n="confirm.discard">Don't save</button>
210
+ <button id="confirmSave" class="primary" data-i18n="confirm.save">Save and return</button>
211
+ </div>
212
+ </div>
213
+ </div>
214
+ <script src="codemirror.js"></script>
215
+ <script>
216
+ (function () {
217
+ 'use strict';
218
+ var api = window.finch;
219
+
220
+ // ---- i18n ----------------------------------------------------------
221
+ // This page is a standalone static document (loaded into a host webview,
222
+ // not a bundled app with its own build-time locale), so translation
223
+ // happens at runtime: guess from navigator.language immediately (so the
224
+ // very first paint isn't wrong-language), then reconcile against the
225
+ // host's real locale once it arrives in the 'ready' handshake message
226
+ // (ctx.i18n.locale, forwarded from index.ts). Only zh/non-zh is
227
+ // distinguished — bm.md's own rendered article content is unaffected,
228
+ // this only covers this panel's own chrome (labels/status/tooltips).
229
+ var isZh = /^zh/i.test(navigator.language || '');
230
+ var STR = {
231
+ zh: {
232
+ 'meta.title': '公众号文章预览',
233
+ 'home.title': 'Markdown 编辑',
234
+ 'home.tagline.withAssistant': '你与{name}一起编排 Markdown 文件',
235
+ 'home.tagline.default': '打开一个 Markdown 文件开始编辑与预览,或新建一篇文章。',
236
+ 'home.openFile': '打开文件',
237
+ 'home.newDoc': '新建文档',
238
+ 'home.recentLabel': '最近编辑',
239
+ 'home.hintScoped': '这个目录里还没有用编辑器打开过的 Markdown 文档。',
240
+ 'home.hintDefault': '打开一个 Markdown 文件,之后这里会列出最近编辑的文档。',
241
+ 'home.untitled': '未命名',
242
+ 'home.emptyDoc': '(空文档)',
243
+ 'home.cwdTitle': '在访达 / 文件资源管理器中打开:{path}',
244
+ 'home.today': '今天 {clock}',
245
+ 'home.yesterday': '昨天 {clock}',
246
+ 'editor.ariaLabel': 'Markdown 编辑器',
247
+ 'actions.copyToWx': '复制到公众号',
248
+ 'actions.copyImg': '复制图片',
249
+ 'actions.exportImg': '导出图片(下载)',
250
+ 'actions.exportPdf': '导出 PDF',
251
+ 'actions.aiStyle': '让 AI 设计排版',
252
+ 'actions.toggle': '展开/收起',
253
+ 'popup.placeholder': '让 {name} 编辑',
254
+ 'popup.send': '发送',
255
+ 'confirm.cancel': '取消',
256
+ 'confirm.discard': '不保存',
257
+ 'confirm.save': '保存并返回',
258
+ 'confirm.message': '「{file}」还有未保存的改动,要保存后再返回首页吗?',
259
+ 'common.markdownDocDefault': 'Markdown 文档',
260
+ 'common.customStyleDefault': '自定义风格',
261
+ 'toolbar.home.tooltip': '返回首页',
262
+ 'toolbar.open.tooltipDefault': '打开 Markdown 文件',
263
+ 'toolbar.save.label': '保存',
264
+ 'toolbar.save.tooltipDefault': '保存(⌘/Ctrl+S)',
265
+ 'toolbar.save.tooltipDirty': '有未保存的改动(⌘/Ctrl+S)',
266
+ 'toolbar.save.labelSaved': '已保存',
267
+ 'toolbar.save.tooltipSaved': '已保存',
268
+ 'toolbar.annotate.label': '批注',
269
+ 'toolbar.annotate.tooltipOn': '批注已开启:选中文字可发起 AI 修改',
270
+ 'toolbar.annotate.tooltipOff': '开启批注:选中文字后可发起 AI 修改',
271
+ 'toolbar.needDoc': '请先打开文档',
272
+ 'toolbar.mode.label': '预览',
273
+ 'toolbar.mode.tooltipToPreview': '切换到预览',
274
+ 'toolbar.mode.tooltipToEdit': '切换到编辑',
275
+ 'toolbar.style.tooltip': '排版风格',
276
+ 'toolbar.style.tooltipNeedsPreview': '请先切换到预览',
277
+ 'toolbar.style.customSlot': '自定义风格 {n} · {label}',
278
+ 'toolbar.style.customSlotEmpty': '自定义风格 {n}(空)',
279
+ 'toolbar.fontSize.tooltipPreview': '预览时不可更改字体',
280
+ 'toolbar.fontSize.tooltipEdit': '编辑器字体大小',
281
+ 'toolbar.fontSize.small': '小',
282
+ 'toolbar.fontSize.medium': '中',
283
+ 'toolbar.fontSize.large': '大',
284
+ 'toolbar.fontFamily.default': '默认(无衬线)',
285
+ 'toolbar.fontFamily.serif': '衬线体',
286
+ 'toolbar.more.reload': '重新渲染',
287
+ 'toolbar.more.about': '关于渲染',
288
+ 'annotate.lineUnknown': '(未能精确定位对应行号,请以下方引用文本为准)',
289
+ 'annotate.lineSingle': '第 {a} 行',
290
+ 'annotate.lineRange': '第 {a}–{b} 行',
291
+ 'annotate.lineCompactUnknown': '未定位',
292
+ 'annotate.requirementWith': '要求:{comment}',
293
+ 'annotate.requirementDefault': '让表达更清晰',
294
+ 'annotate.pathMissing': '(未关联本地文件,请先粘贴/保存全部内容)',
295
+ 'annotate.promptText': '请改写这段 Markdown 内容(位置:{lineLabel}),{requirement}:\n\n{text}\n\n给出可直接替换的片段;我确认后调用 markdown_editor_document 的 apply 动作,用更新后的完整内容覆盖源文件(path={path})。',
296
+ 'annotate.reminder': '仅给出改写建议,不要在未确认前直接写入源文件。',
297
+ 'newdoc.needChat': '请在 Finch 对话中使用“新建文档”。',
298
+ 'newdoc.label': '新建 Markdown 文档',
299
+ 'newdoc.promptText': '我想新建一篇 Markdown 文档。请先根据我的要求确定标题、内容与一个尚不存在的绝对保存路径;信息不足时先问我。确认后调用 markdown_editor_document 的 create 动作,传入完整 Markdown 正文和绝对 path,并在编辑器中打开新文件。',
300
+ 'newdoc.reminder': '新建文件时必须使用 markdown_editor_document 的 create 动作;不要覆盖已有文件。',
301
+ 'newdoc.added': '已把新建文档引导加入输入框。',
302
+ 'aiStyle.label': '根据 {file} 设计风格',
303
+ 'aiStyle.note': 'AI 一键排版',
304
+ 'aiStyle.baseNoteCustom': '当前基础风格是 kami(自定义 CSS 叠加其上)',
305
+ 'aiStyle.baseNoteOther': '当前基础风格是 {style}',
306
+ 'aiStyle.pathMissing': '(未关联本地文件)',
307
+ 'aiStyle.promptText': '请为这篇公众号文章设计一套自定义排版 CSS。{baseNote},你的 CSS 会叠加在基础风格之上。要求:只写普通 CSS 规则,选择器限定在 #bm-md 下的标签/结构(如 #bm-md h1、#bm-md p、#bm-md blockquote、#bm-md pre code、#bm-md a、#bm-md strong、#bm-md table 等),不要使用 class(bm.md 输出没有 class,只有内联样式),必要时用 !important 覆盖基础风格。可参考 bm.md 内置风格的设计语言:kami(暖色纸感)、bauhaus(几何撞色)、blueprint(技术蓝图网格)、botanical(清新绿意)、newsprint(报刊衬线)、retro(复古怀旧)、sketch(手绘风)、terminal(等宽暗色终端风)。文章路径:{path}。动手生成 CSS 前,先询问用户要覆盖「自定义风格 1、2、3」中的哪一个槽位;用户选择后,再调用 markdown_editor_document 的 set_style,必须传入 css、简短 label 和 slot(1/2/3)。该调用会覆盖并持久化那个槽位,同时立即应用预览。',
308
+ 'aiStyle.reminder': '必须先询问槽位,不能自行猜测或覆盖;用户明确选定 1/2/3 后才调用 set_style。',
309
+ 'aiStyle.requested': '已请求 AI 设计自定义排版。',
310
+ 'status.saved': '已保存。',
311
+ 'status.savedWithNewChanges': '已保存,编辑器中仍有新改动。',
312
+ 'status.saveFailed': '保存失败:{err}',
313
+ 'status.saving': '正在保存…',
314
+ 'status.noSourceFile': '未关联本地文件:点击工具栏「打开」重新选择一次即可关联并启用保存。',
315
+ 'status.pickerTimeout': '文件选择器长时间无响应,已切换为浏览器内选择器:请重新点击"打开"。',
316
+ 'status.externalUpdateSkipped': '检测到文件外部更新;为避免覆盖未保存内容,暂未载入。',
317
+ 'status.externalUpdateApplied': '检测到文件更新,已刷新。',
318
+ 'status.openingPicker': '正在打开文件选择器…',
319
+ 'status.opening': '正在打开…',
320
+ 'status.opened': '已打开 {file}。',
321
+ 'status.contentUpdated': '内容已更新。',
322
+ 'status.pasteImageTimeout': '粘贴图片超时',
323
+ 'status.pasteImageFailed': '粘贴图片失败:{msg}',
324
+ 'status.clipboardPrepareTimeout': '准备公众号图片超时',
325
+ 'status.pleaseOpenArticle': '请先打开文章。',
326
+ 'status.copiedRich': '公众号富文本 HTML 已复制,可直接粘贴到公众号编辑器。',
327
+ 'status.copiedPlainOnly': '仅复制到纯文本,格式未能带上,请重试一次。',
328
+ 'status.copyFailed': '复制失败,请重试一次。',
329
+ 'status.richCopyFailed': '富文本复制失败:{err}',
330
+ 'status.clipboardUnsupported': '当前环境不支持复制图片,请改用「导出图片」。',
331
+ 'status.generatingImage': '正在生成图片…',
332
+ 'status.previewNotReady': '预览尚未就绪',
333
+ 'status.imageCopied': '图片已复制到剪贴板。',
334
+ 'status.copyImageFailed': '复制图片失败:{err}(可改用「导出图片」)',
335
+ 'status.exportImageFailedGeneric': '导出图片失败:预览尚未就绪。',
336
+ 'status.exportImageFailed': '导出图片失败:{err}',
337
+ 'status.generatingPdf': '正在生成 PDF…',
338
+ 'status.exportPdfFailedGeneric': '导出 PDF 失败:预览尚未就绪。',
339
+ 'status.exportPdfFailed': '导出 PDF 失败:{err}',
340
+ 'status.exported': '已导出:{path}',
341
+ 'status.annotateOn': '批注已开启:选中文字可发起 AI 修改。',
342
+ 'status.annotateOff': '批注已关闭。',
343
+ 'status.saveFailedNoReturn': '保存失败,未返回首页。',
344
+ 'status.rendererAbout': '渲染器:本机 bmmd CLI(bm.md,AGPL-3.0)。',
345
+ 'status.appliedStyleSlot': '已应用「{label}」。',
346
+ 'status.needCustomStyleFirst': '请先应用一个自定义/AI 设计的风格,再保存到槽位。',
347
+ 'status.savedToSlot': '已保存到槽位 {slot}:{label}',
348
+ 'status.rendering': '正在渲染…',
349
+ 'status.serializeFailed': '无法序列化预览内容:{err}',
350
+ 'status.imageConversionFailed': '预览内容无法转成图片(可能含有无法跨域读取的外部图片)',
351
+ 'status.imageGenFailed': '生成图片数据失败',
352
+ 'status.clipboardPrepareFailedDefault': '准备公众号图片失败',
353
+ 'status.pasteImageFailedDefault': '粘贴图片失败',
354
+ 'status.savedAppliedToSlot': '{labelPrefix}已保存并应用到自定义风格 {slot}。',
355
+ },
356
+ en: {
357
+ 'meta.title': 'Article Preview',
358
+ 'home.title': 'Markdown Editor',
359
+ 'home.tagline.withAssistant': 'Write Markdown together with {name}',
360
+ 'home.tagline.default': 'Open a Markdown file to start editing and previewing, or create a new article.',
361
+ 'home.openFile': 'Open File',
362
+ 'home.newDoc': 'New Document',
363
+ 'home.recentLabel': 'Recent',
364
+ 'home.hintScoped': 'No Markdown documents have been opened from this folder yet.',
365
+ 'home.hintDefault': 'Open a Markdown file — recently edited documents will show up here.',
366
+ 'home.untitled': 'Untitled',
367
+ 'home.emptyDoc': '(Empty document)',
368
+ 'home.cwdTitle': 'Open in Finder / File Explorer: {path}',
369
+ 'home.today': 'Today {clock}',
370
+ 'home.yesterday': 'Yesterday {clock}',
371
+ 'editor.ariaLabel': 'Markdown editor',
372
+ 'actions.copyToWx': 'Copy for WeChat',
373
+ 'actions.copyImg': 'Copy image',
374
+ 'actions.exportImg': 'Export image (download)',
375
+ 'actions.exportPdf': 'Export PDF',
376
+ 'actions.aiStyle': 'Ask AI to design layout',
377
+ 'actions.toggle': 'Expand/collapse',
378
+ 'popup.placeholder': 'Let {name} edit',
379
+ 'popup.send': 'Send',
380
+ 'confirm.cancel': 'Cancel',
381
+ 'confirm.discard': "Don't save",
382
+ 'confirm.save': 'Save and return',
383
+ 'confirm.message': '\u201c{file}\u201d has unsaved changes. Save before returning to Home?',
384
+ 'common.markdownDocDefault': 'Markdown document',
385
+ 'common.customStyleDefault': 'Custom style',
386
+ 'toolbar.home.tooltip': 'Back to Home',
387
+ 'toolbar.open.tooltipDefault': 'Open Markdown file',
388
+ 'toolbar.save.label': 'Save',
389
+ 'toolbar.save.tooltipDefault': 'Save (\u2318/Ctrl+S)',
390
+ 'toolbar.save.tooltipDirty': 'Unsaved changes (\u2318/Ctrl+S)',
391
+ 'toolbar.save.labelSaved': 'Saved',
392
+ 'toolbar.save.tooltipSaved': 'Saved',
393
+ 'toolbar.annotate.label': 'Annotate',
394
+ 'toolbar.annotate.tooltipOn': 'Annotate is on — select text to ask AI to revise it',
395
+ 'toolbar.annotate.tooltipOff': 'Turn on Annotate: select text to ask AI to revise it',
396
+ 'toolbar.needDoc': 'Open a document first',
397
+ 'toolbar.mode.label': 'Preview',
398
+ 'toolbar.mode.tooltipToPreview': 'Switch to preview',
399
+ 'toolbar.mode.tooltipToEdit': 'Switch to edit',
400
+ 'toolbar.style.tooltip': 'Layout style',
401
+ 'toolbar.style.tooltipNeedsPreview': 'Switch to preview first',
402
+ 'toolbar.style.customSlot': 'Custom style {n} · {label}',
403
+ 'toolbar.style.customSlotEmpty': 'Custom style {n} (empty)',
404
+ 'toolbar.fontSize.tooltipPreview': "Font can't be changed while previewing",
405
+ 'toolbar.fontSize.tooltipEdit': 'Editor font size',
406
+ 'toolbar.fontSize.small': 'Small',
407
+ 'toolbar.fontSize.medium': 'Medium',
408
+ 'toolbar.fontSize.large': 'Large',
409
+ 'toolbar.fontFamily.default': 'Default (sans-serif)',
410
+ 'toolbar.fontFamily.serif': 'Serif',
411
+ 'toolbar.more.reload': 'Re-render',
412
+ 'toolbar.more.about': 'About rendering',
413
+ 'annotate.lineUnknown': "(Couldn't pinpoint the exact line — use the quoted text below as reference)",
414
+ 'annotate.lineSingle': 'Line {a}',
415
+ 'annotate.lineRange': 'Lines {a}\u2013{b}',
416
+ 'annotate.lineCompactUnknown': 'Unlocated',
417
+ 'annotate.requirementWith': 'Requirement: {comment}',
418
+ 'annotate.requirementDefault': 'make the wording clearer',
419
+ 'annotate.pathMissing': '(No local file linked — ask me to paste/save the full text first)',
420
+ 'annotate.promptText': 'Please rewrite this Markdown passage (location: {lineLabel}), {requirement}:\n\n{text}\n\nGive a drop-in replacement snippet; once I confirm, call markdown_editor_document\u2019s apply action to overwrite the source file (path={path}) with the updated content.',
421
+ 'annotate.reminder': "Only propose the rewrite — don't write to the source file before I confirm.",
422
+ 'newdoc.needChat': 'Use \u201cNew Document\u201d from the Finch chat instead.',
423
+ 'newdoc.label': 'New Markdown Document',
424
+ 'newdoc.promptText': "I want to create a new Markdown document. First work out the title, content, and a not-yet-existing absolute save path from my request — ask me first if anything is missing. Once confirmed, call markdown_editor_document's create action with the full Markdown body and absolute path to write and open the new file.",
425
+ 'newdoc.reminder': "Always use markdown_editor_document's create action for new files; never overwrite an existing file.",
426
+ 'newdoc.added': 'Added the new-document prompt to the composer.',
427
+ 'aiStyle.label': 'Design a layout based on {file}',
428
+ 'aiStyle.note': 'One-click AI layout',
429
+ 'aiStyle.baseNoteCustom': 'The current base style is kami (your custom CSS layers on top of it)',
430
+ 'aiStyle.baseNoteOther': 'The current base style is {style}',
431
+ 'aiStyle.pathMissing': '(No local file linked)',
432
+ 'aiStyle.promptText': 'Please design a custom layout CSS for this WeChat article. {baseNote}, and your CSS will layer on top of the base style. Requirements: write plain CSS rules only, with selectors scoped to tags/structure under #bm-md (e.g. #bm-md h1, #bm-md p, #bm-md blockquote, #bm-md pre code, #bm-md a, #bm-md strong, #bm-md table); don\u2019t use classes (bm.md\u2019s output has no classes, only inline styles), and use !important where needed to override the base style. You can draw on bm.md\u2019s built-in style language: kami (warm paper feel), bauhaus (geometric color-blocking), blueprint (technical grid), botanical (fresh green), newsprint (editorial serif), retro (nostalgic), sketch (hand-drawn), terminal (monospace dark). Article path: {path}. Before generating any CSS, ask the user which of \u201cCustom style 1/2/3\u201d slots to overwrite; once they choose, call markdown_editor_document\u2019s set_style with css, a short label, and slot (1/2/3). That call overwrites and persists that slot and applies it to the preview immediately.',
433
+ 'aiStyle.reminder': "Always ask which slot first — never guess or overwrite on your own; call set_style only after the user picks 1/2/3.",
434
+ 'aiStyle.requested': 'Asked AI to design a custom layout.',
435
+ 'status.saved': 'Saved.',
436
+ 'status.savedWithNewChanges': "Saved — but the editor already has newer changes.",
437
+ 'status.saveFailed': 'Save failed: {err}',
438
+ 'status.saving': 'Saving…',
439
+ 'status.noSourceFile': 'Not linked to a local file: click “Open” in the toolbar again to link it and enable saving.',
440
+ 'status.pickerTimeout': 'The file picker took too long to respond and switched to the in-browser picker — please click "Open" again.',
441
+ 'status.externalUpdateSkipped': 'Detected an external file update; not loaded yet to avoid overwriting unsaved changes.',
442
+ 'status.externalUpdateApplied': 'Detected a file update and refreshed.',
443
+ 'status.openingPicker': 'Opening the file picker…',
444
+ 'status.opening': 'Opening…',
445
+ 'status.opened': 'Opened {file}.',
446
+ 'status.contentUpdated': 'Content updated.',
447
+ 'status.pasteImageTimeout': 'Paste image timed out',
448
+ 'status.pasteImageFailed': 'Paste image failed: {msg}',
449
+ 'status.clipboardPrepareTimeout': 'Preparing WeChat images timed out',
450
+ 'status.pleaseOpenArticle': 'Open an article first.',
451
+ 'status.copiedRich': 'Rich HTML copied — paste it directly into the WeChat editor.',
452
+ 'status.copiedPlainOnly': "Only plain text was copied; formatting didn't come through — please try again.",
453
+ 'status.copyFailed': 'Copy failed — please try again.',
454
+ 'status.richCopyFailed': 'Rich-text copy failed: {err}',
455
+ 'status.clipboardUnsupported': 'Copying images isn\u2019t supported here — use \u201cExport Image\u201d instead.',
456
+ 'status.generatingImage': 'Generating image…',
457
+ 'status.previewNotReady': "Preview isn't ready yet",
458
+ 'status.imageCopied': 'Image copied to the clipboard.',
459
+ 'status.copyImageFailed': 'Copy image failed: {err} (try \u201cExport Image\u201d instead)',
460
+ 'status.exportImageFailedGeneric': "Export image failed: preview isn't ready yet.",
461
+ 'status.exportImageFailed': 'Export image failed: {err}',
462
+ 'status.generatingPdf': 'Generating PDF…',
463
+ 'status.exportPdfFailedGeneric': "Export PDF failed: preview isn't ready yet.",
464
+ 'status.exportPdfFailed': 'Export PDF failed: {err}',
465
+ 'status.exported': 'Exported: {path}',
466
+ 'status.annotateOn': 'Annotate is on — select text to ask AI to revise it.',
467
+ 'status.annotateOff': 'Annotate is off.',
468
+ 'status.saveFailedNoReturn': 'Save failed — staying on this document.',
469
+ 'status.rendererAbout': 'Renderer: local bmmd CLI (bm.md, AGPL-3.0).',
470
+ 'status.appliedStyleSlot': 'Applied \u201c{label}\u201d.',
471
+ 'status.needCustomStyleFirst': 'Apply a custom/AI-designed style first before saving it to a slot.',
472
+ 'status.savedToSlot': 'Saved to slot {slot}: {label}',
473
+ 'status.rendering': 'Rendering…',
474
+ 'status.serializeFailed': 'Failed to serialize the preview content: {err}',
475
+ 'status.imageConversionFailed': "The preview couldn't be converted to an image (it may contain external images that can't be read cross-origin)",
476
+ 'status.imageGenFailed': 'Failed to generate image data',
477
+ 'status.clipboardPrepareFailedDefault': 'Failed to prepare WeChat images',
478
+ 'status.pasteImageFailedDefault': 'Paste image failed',
479
+ 'status.savedAppliedToSlot': '{labelPrefix}Saved and applied to custom style {slot}.',
480
+ },
481
+ };
482
+ function t(key, vars) {
483
+ var dict = isZh ? STR.zh : STR.en;
484
+ var s = Object.prototype.hasOwnProperty.call(dict, key) ? dict[key]
485
+ : (Object.prototype.hasOwnProperty.call(STR.en, key) ? STR.en[key] : key);
486
+ if (vars) {
487
+ for (var k in vars) {
488
+ if (Object.prototype.hasOwnProperty.call(vars, k)) s = s.split('{' + k + '}').join(String(vars[k]));
489
+ }
490
+ }
491
+ return s;
492
+ }
493
+ function applyStaticI18n(root) {
494
+ var scope = root || document;
495
+ document.documentElement.lang = isZh ? 'zh-CN' : 'en';
496
+ document.title = t('meta.title');
497
+ Array.prototype.forEach.call(scope.querySelectorAll('[data-i18n]'), function (el) {
498
+ el.textContent = t(el.getAttribute('data-i18n'));
499
+ });
500
+ Array.prototype.forEach.call(scope.querySelectorAll('[data-i18n-title]'), function (el) {
501
+ el.title = t(el.getAttribute('data-i18n-title'));
502
+ });
503
+ Array.prototype.forEach.call(scope.querySelectorAll('[data-i18n-aria]'), function (el) {
504
+ el.setAttribute('aria-label', t(el.getAttribute('data-i18n-aria')));
505
+ });
506
+ }
507
+
508
+ var empty = document.getElementById('empty');
509
+ var emptyNew = document.getElementById('emptyNew');
510
+ var emptyOpen = document.getElementById('emptyOpen');
511
+ var homeRecent = document.getElementById('homeRecent');
512
+ var homeGrid = document.getElementById('homeGrid');
513
+ var homeCwd = document.getElementById('homeCwd');
514
+ var homeHint = document.getElementById('homeHint');
515
+ var homeTagline = document.getElementById('homeTagline');
516
+ var editPane = document.getElementById('editPane');
517
+ var previewPane = document.getElementById('previewPane');
518
+ var editor = document.getElementById('editor');
519
+ var frame = document.getElementById('article');
520
+ var statusEl = document.getElementById('status');
521
+ var popup = document.getElementById('popup');
522
+ var popupInput = document.getElementById('popupInput');
523
+ var popupSend = document.getElementById('popupSend');
524
+ var actions = document.getElementById('actions');
525
+ var actCopyWx = document.getElementById('actCopyWx');
526
+ var actCopyImg = document.getElementById('actCopyImg');
527
+ var actSaveImg = document.getElementById('actSaveImg');
528
+ var actSavePdf = document.getElementById('actSavePdf');
529
+ var actAiStyle = document.getElementById('actAiStyle');
530
+ var actToggle = document.getElementById('actToggle');
531
+ var confirmOverlay = document.getElementById('confirmOverlay');
532
+ var confirmMessage = document.getElementById('confirmMessage');
533
+ var confirmCancel = document.getElementById('confirmCancel');
534
+ var confirmDiscard = document.getElementById('confirmDiscard');
535
+ var confirmSave = document.getElementById('confirmSave');
536
+
537
+ var pickFileSupported = false; // set from the backend 'ready' message
538
+ var annotationsEnabled = false; // off by default; toggled from the toolbar "annotate" button
539
+ var mode = 'edit'; // 'edit' | 'preview'
540
+ var style = 'kami';
541
+ var customCss = '';
542
+ var customStyleLabel = ''; // label of the currently-applied AI/custom CSS, if any
543
+ // 3 reusable custom-style slots, persisted host-side (global — shared by
544
+ // every document/panel, not per-session) so a style designed for one
545
+ // article can be reapplied to the next without asking AI to redesign it.
546
+ // Populated from the 'ready' message; kept in sync via 'styleSlots'.
547
+ var styleSlots = [null, null, null];
548
+ // Editor-only preferences. The font stacks are system-first: no webfont
549
+ // downloads, while macOS/Windows/Linux each get a native matching face.
550
+ var EDITOR_FONTS = {
551
+ songti: 'Songti SC, STSong, SimSun, Noto Serif CJK SC, serif',
552
+ rounded: 'PingFang SC, Microsoft YaHei, Noto Sans CJK SC, sans-serif'
553
+ };
554
+ var editorFontSize = 14;
555
+ var editorFont = 'rounded';
556
+ try {
557
+ // Only the three menu tiers are valid; anything else (missing, corrupt,
558
+ // or from an older tier set) falls back to the default 14px tier.
559
+ var savedFontSize = Number(localStorage.getItem('md-editor-font-size'));
560
+ if (savedFontSize === 14 || savedFontSize === 16 || savedFontSize === 18) editorFontSize = savedFontSize;
561
+ var savedEditorFont = localStorage.getItem('md-editor-font-family');
562
+ if (EDITOR_FONTS[savedEditorFont]) editorFont = savedEditorFont;
563
+ else if (savedEditorFont) localStorage.removeItem('md-editor-font-family');
564
+ } catch (e) {}
565
+ var markdown = '';
566
+ var name = '';
567
+ var fileName = '';
568
+ var sourcePath = '';
569
+ var savedMarkdown = null; // last markdown value confirmed written to disk
570
+ var html = '';
571
+ var frameReady = false;
572
+ var renderId = 0;
573
+ var saveId = 0;
574
+ var pendingSaveContents = {};
575
+ // Resolvers for callers that need to *await* a save round-trip (the
576
+ // unsaved-changes confirm below) rather than fire-and-forget like the
577
+ // Save button / Cmd+S. Keyed by the same saveId as pendingSaveContents.
578
+ var pendingSaveResolvers = {};
579
+ var pasteImageId = 0;
580
+ var pendingPasteImages = {};
581
+ var clipboardImageId = 0;
582
+ var pendingClipboardImages = {};
583
+ var savedFlashTimer = 0;
584
+ var fileHandle = null;
585
+ var lastModified = 0;
586
+ var watchTimer = 0;
587
+ var watchBusy = false;
588
+ var selection = { text: '' };
589
+
590
+ var statusHideTimer = 0;
591
+ function setStatus(text, isError) {
592
+ if (statusHideTimer) { clearTimeout(statusHideTimer); statusHideTimer = 0; }
593
+ statusEl.textContent = text || '';
594
+ statusEl.className = 'status' + (isError ? ' error' : '');
595
+ // Auto-clear after 5s so a stale "已保存"/error message doesn't linger
596
+ // in the footer forever; an empty message needs no timer at all.
597
+ if (text) statusHideTimer = setTimeout(function () {
598
+ statusHideTimer = 0;
599
+ statusEl.textContent = '';
600
+ statusEl.className = 'status';
601
+ }, 5000);
602
+ }
603
+
604
+ function hasDocument() { return !!markdown || !!sourcePath; }
605
+
606
+ if (emptyNew) emptyNew.addEventListener('click', async function () {
607
+ if (!api || !api.composer) {
608
+ setStatus(t('newdoc.needChat'), true);
609
+ return;
610
+ }
611
+ try {
612
+ await api.composer.addContexts([{
613
+ type: 'annotation',
614
+ label: t('newdoc.label'),
615
+ promptText: t('newdoc.promptText'),
616
+ reminder: t('newdoc.reminder'),
617
+ }]);
618
+ setStatus(t('newdoc.added'));
619
+ } catch (e) {
620
+ setStatus(String(e), true);
621
+ }
622
+ });
623
+
624
+ if (emptyOpen) emptyOpen.addEventListener('click', function () { openFile(); });
625
+
626
+ // ── Home: recent documents ────────────────────────────────────────────
627
+ // The working directory arrives with the platform's own `finch:env` push;
628
+ // the backend scopes its recent-file list to whatever we hand back, so a
629
+ // panel in another Space never shows this Space's documents.
630
+ var envCwd = '';
631
+ var recentRequested = false;
632
+ var assistantName = '';
633
+ var homeDir = '';
634
+
635
+ function escapeHtml(text) {
636
+ return String(text).replace(/[&<>"]/g, function (ch) {
637
+ return ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : '&quot;';
638
+ });
639
+ }
640
+
641
+ function updateHomeTagline() {
642
+ if (!homeTagline) return;
643
+ homeTagline.textContent = assistantName
644
+ ? t('home.tagline.withAssistant', { name: assistantName })
645
+ : t('home.tagline.default');
646
+ }
647
+
648
+ // "让 AI 编辑" placeholder becomes "让 <assistant name> 编辑"
649
+ // once the real assistant name arrives — same withAssistant/default split
650
+ // as the tagline above, so the popup never claims to be a generic "AI"
651
+ // when it's actually this session's named assistant. Fallback "Finch"
652
+ // mirrors the backend's own getAssistantName() default, so even the
653
+ // pre-handshake flash never says "AI".
654
+ function updatePopupPlaceholder() {
655
+ if (!popupInput) return;
656
+ popupInput.placeholder = t('popup.placeholder', { name: assistantName || 'Finch' });
657
+ }
658
+
659
+ // OS-friendly display only — the raw absolute path is still what's sent
660
+ // to the backend for the "reveal in file manager" click. homeDir arrives
661
+ // with the platform's own separator (Windows: "C:\Users\name", so check
662
+ // both '/' and '\\' — a plain '/'-only check would never match there and
663
+ // Windows paths would simply show in full, unabbreviated, which is fine).
664
+ function formatFriendlyPath(absPath) {
665
+ if (!absPath) return '';
666
+ if (homeDir && absPath === homeDir) return '~';
667
+ if (homeDir && (absPath.indexOf(homeDir + '/') === 0 || absPath.indexOf(homeDir + '\\') === 0)) {
668
+ return '~' + absPath.slice(homeDir.length);
669
+ }
670
+ return absPath;
671
+ }
672
+
673
+ // Manual front-truncation ("…/end/of/path") instead of CSS text-overflow
674
+ // (which trims the end, hiding the most useful — innermost — folder name).
675
+ // Character-count based rather than pixel-measured: good enough for a
676
+ // secondary label, and the CSS ellipsis on .home-cwd still backstops any
677
+ // panel narrower than this budget assumes.
678
+ function truncateFriendlyPath(text) {
679
+ var MAX = 46;
680
+ if (!text || text.length <= MAX) return text;
681
+ var sep = text.indexOf('/') === -1 && text.indexOf('\\') !== -1 ? '\\' : '/';
682
+ var tail = text.slice(-(MAX - 1));
683
+ var sepIdx = tail.indexOf(sep);
684
+ if (sepIdx > 0 && sepIdx < 10) tail = tail.slice(sepIdx);
685
+ return '…' + tail;
686
+ }
687
+
688
+ function renderHomeCwd() {
689
+ if (!homeCwd) return;
690
+ homeCwd.textContent = truncateFriendlyPath(formatFriendlyPath(envCwd));
691
+ homeCwd.title = envCwd ? t('home.cwdTitle', { path: envCwd }) : '';
692
+ homeCwd.hidden = !envCwd;
693
+ }
694
+
695
+ if (homeCwd) homeCwd.addEventListener('click', function () {
696
+ if (!envCwd || !api || !api.postMessage) return;
697
+ api.postMessage({ type: 'openPath', path: envCwd });
698
+ });
699
+
700
+ function formatDocTime(ts) {
701
+ var date = new Date(ts);
702
+ if (!ts || isNaN(date.getTime())) return '';
703
+ var now = new Date();
704
+ var pad = function (n) { return n < 10 ? '0' + n : String(n); };
705
+ var clock = pad(date.getHours()) + ':' + pad(date.getMinutes());
706
+ var sameDay = date.toDateString() === now.toDateString();
707
+ if (sameDay) return t('home.today', { clock: clock });
708
+ var yesterday = new Date(now.getTime() - 86400000);
709
+ if (date.toDateString() === yesterday.toDateString()) return t('home.yesterday', { clock: clock });
710
+ if (date.getFullYear() === now.getFullYear()) return pad(date.getMonth() + 1) + '-' + pad(date.getDate());
711
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
712
+ }
713
+
714
+ function requestRecentDocuments() {
715
+ if (!api || !api.postMessage || !envCwd) return;
716
+ recentRequested = true;
717
+ api.postMessage({ type: 'requestRecentDocuments', cwd: envCwd });
718
+ }
719
+
720
+ function renderRecentDocuments(documents) {
721
+ if (!homeGrid || !homeRecent || !homeHint) return;
722
+ var list = Array.isArray(documents) ? documents : [];
723
+ if (list.length === 0) {
724
+ homeRecent.hidden = true;
725
+ homeHint.hidden = false;
726
+ homeHint.textContent = envCwd
727
+ ? t('home.hintScoped')
728
+ : t('home.hintDefault');
729
+ return;
730
+ }
731
+ var cards = '';
732
+ for (var i = 0; i < list.length; i++) {
733
+ var doc = list[i] || {};
734
+ cards += '<button class="doc-card" type="button" data-path="' + escapeHtml(doc.path || '') + '" title="' + escapeHtml(doc.path || '') + '">'
735
+ + '<div class="doc-preview">' + escapeHtml(doc.preview || t('home.emptyDoc')) + '</div>'
736
+ + '<div class="doc-foot">'
737
+ + '<div class="doc-title">' + escapeHtml(doc.title || doc.fileName || t('home.untitled')) + '</div>'
738
+ + '<div class="doc-sub"><span class="doc-path">' + escapeHtml(doc.relativePath || doc.fileName || '') + '</span>'
739
+ + '<span class="doc-time">' + escapeHtml(formatDocTime(doc.modifiedAt)) + '</span></div>'
740
+ + '</div></button>';
741
+ }
742
+ homeGrid.innerHTML = cards;
743
+ homeRecent.hidden = false;
744
+ homeHint.hidden = true;
745
+ }
746
+
747
+ // Set while a Home-card click is in flight, so the `document` reply below
748
+ // can turn "正在打开…" into a completion message instead of leaving it
749
+ // to linger (or getting silently swallowed by the "isFirst" branch, which
750
+ // otherwise prints nothing at all for the very first document a panel loads).
751
+ var loadingFromHome = false;
752
+
753
+ if (homeGrid) homeGrid.addEventListener('click', function (event) {
754
+ var card = event.target && event.target.closest ? event.target.closest('.doc-card') : null;
755
+ if (!card || !api || !api.postMessage) return;
756
+ var docPath = card.getAttribute('data-path') || '';
757
+ if (!docPath) return;
758
+ loadingFromHome = true;
759
+ setStatus(t('status.opening'));
760
+ api.postMessage({ type: 'loadPath', path: docPath });
761
+ });
762
+
763
+ function updateEmptyState() {
764
+ empty.hidden = hasDocument();
765
+ if (actions) actions.hidden = !html;
766
+ // Refresh whenever the home page comes back into view: mtimes change while
767
+ // a document is open, and a file may have been created since last render.
768
+ if (!empty.hidden && envCwd) requestRecentDocuments();
769
+ }
770
+
771
+ function showPane() {
772
+ editPane.classList.toggle('active', mode === 'edit');
773
+ previewPane.classList.toggle('active', mode === 'preview');
774
+ if (mode === 'edit' && cm) requestAnimationFrame(function () { cm.layout(); });
775
+ }
776
+
777
+ function setPanelTitle(title) {
778
+ if (!api || !api.panel || !api.panel.setTitle) return;
779
+ api.panel.setTitle(title || t('home.title')).catch(function () {});
780
+ }
781
+
782
+ // 8 built-in presets, then a separator, then the 3 reusable AI-managed
783
+ // slots. The AI-custom entry itself was removed from this menu — AI now
784
+ // asks which slot to overwrite before calling set_style, so users only
785
+ // need this menu to pick a preset or reapply a saved slot.
786
+ function buildStyleMenuItems() {
787
+ var items = [
788
+ { id: 'style:kami', label: 'Kami' },
789
+ { id: 'style:bauhaus', label: 'Bauhaus' },
790
+ { id: 'style:blueprint', label: 'Blueprint' },
791
+ { id: 'style:botanical', label: 'Botanical' },
792
+ { id: 'style:newsprint', label: 'Newsprint' },
793
+ { id: 'style:retro', label: 'Retro' },
794
+ { id: 'style:sketch', label: 'Sketch' },
795
+ { id: 'style:terminal', label: 'Terminal' },
796
+ { id: 'style-sep', label: '', separator: true },
797
+ ];
798
+ for (var i = 0; i < 3; i++) {
799
+ var slot = styleSlots[i];
800
+ items.push(slot
801
+ ? { id: 'style:slot-use:' + i, label: t('toolbar.style.customSlot', { n: i + 1, label: slot.label }) }
802
+ : { id: 'style:slot-use:' + i, label: t('toolbar.style.customSlotEmpty', { n: i + 1 }), disabled: true });
803
+ }
804
+ return items;
805
+ }
806
+
807
+ // The whole toolbar is rebuilt and pushed as one array (rather than patching
808
+ // single items) so icon/label/disabled/checked states never drift out of
809
+ // sync with each other, and so `ext:` icon ids are always resolved fresh
810
+ // against the already-registered icon pack instead of racing it.
811
+ function buildToolbar() {
812
+ var saveIcon = 'ext:markdown-editor-icons/save';
813
+ var saveLabel = t('toolbar.save.label'), saveTooltip = t('toolbar.save.tooltipDefault');
814
+ if (dirty) {
815
+ // No extra "•" glyph — an enabled/highlighted save button already
816
+ // signals "there's something to save" without adding visual noise.
817
+ saveLabel = t('toolbar.save.label');
818
+ saveTooltip = t('toolbar.save.tooltipDirty');
819
+ } else if (savedFlash) {
820
+ saveIcon = 'ext:markdown-editor-icons/save-check';
821
+ saveLabel = t('toolbar.save.labelSaved');
822
+ saveTooltip = t('toolbar.save.tooltipSaved');
823
+ }
824
+ var hasDoc = hasDocument();
825
+ return [
826
+ {
827
+ // Always available — a one-click way back to the recent-documents
828
+ // launch page, regardless of what's currently open.
829
+ id: 'home', icon: 'house', tooltip: t('toolbar.home.tooltip'),
830
+ },
831
+ {
832
+ // Icon-only: the open action is unambiguous from the folder icon,
833
+ // and dropping the label keeps this crowded toolbar's left edge compact.
834
+ id: 'open', icon: 'ext:markdown-editor-icons/folder-open',
835
+ tooltip: sourcePath || fileName || t('toolbar.open.tooltipDefault'), disabled: nativePickPending,
836
+ },
837
+ { id: 'save', icon: saveIcon, label: saveLabel, tooltip: saveTooltip, disabled: !dirty },
838
+ { type: 'separator' },
839
+ {
840
+ // Labeled, unlike 'open': annotate is a mode toggle (checked state)
841
+ // rather than a one-shot action, so a visible label makes the
842
+ // on/off state legible at a glance instead of relying on hover.
843
+ // Both this and preview need an open document to mean anything.
844
+ id: 'annotate', icon: 'ext:markdown-editor-icons/file-pen-line', label: t('toolbar.annotate.label'),
845
+ tooltip: hasDoc ? (annotationsEnabled ? t('toolbar.annotate.tooltipOn') : t('toolbar.annotate.tooltipOff')) : t('toolbar.needDoc'),
846
+ checked: annotationsEnabled, disabled: !hasDoc,
847
+ },
848
+ {
849
+ // Label stays "预览" no matter which mode is active — the icon
850
+ // (toggle-left/right) plus `checked` already communicate on/off;
851
+ // flipping the word itself between "预览"/"编辑" read as ambiguous
852
+ // ("is this the mode I'm in, or the one I'm switching to?").
853
+ id: 'mode', icon: mode === 'edit' ? 'toggle-left' : 'toggle-right',
854
+ label: t('toolbar.mode.label'), tooltip: hasDoc ? (mode === 'edit' ? t('toolbar.mode.tooltipToPreview') : t('toolbar.mode.tooltipToEdit')) : t('toolbar.needDoc'),
855
+ checked: mode === 'preview', disabled: !hasDoc,
856
+ },
857
+ {
858
+ type: 'menu', id: 'style', icon: 'palette', tooltip: mode === 'preview' ? t('toolbar.style.tooltip') : t('toolbar.style.tooltipNeedsPreview'),
859
+ disabled: mode !== 'preview',
860
+ items: buildStyleMenuItems(),
861
+ },
862
+ { type: 'spacer' },
863
+ // NOTE: copy/export/AI-layout deliberately do NOT live here. A host
864
+ // toolbar click reaches this page as a postMessage, which carries no
865
+ // user activation, so clipboard writes and composer.addContexts() were
866
+ // both being rejected ("This action requires a user gesture") until
867
+ // the user first clicked somewhere in the page. Those actions now sit
868
+ // in the in-page floating bar over the preview (see `.actions` above),
869
+ // where a click is a real user gesture and they succeed on first try.
870
+ {
871
+ type: 'menu', id: 'font-size', icon: 'ext:markdown-editor-icons/type',
872
+ tooltip: !hasDoc ? t('toolbar.needDoc') : mode === 'preview' ? t('toolbar.fontSize.tooltipPreview') : t('toolbar.fontSize.tooltipEdit'),
873
+ disabled: !hasDoc || mode === 'preview',
874
+ items: [
875
+ { id: 'font-size:14', label: t('toolbar.fontSize.small'), checked: editorFontSize === 14 },
876
+ { id: 'font-size:16', label: t('toolbar.fontSize.medium'), checked: editorFontSize === 16 },
877
+ { id: 'font-size:18', label: t('toolbar.fontSize.large'), checked: editorFontSize === 18 },
878
+ { id: 'font-sep', label: '', separator: true },
879
+ { id: 'font-family:rounded', label: t('toolbar.fontFamily.default'), checked: editorFont === 'rounded' },
880
+ { id: 'font-family:songti', label: t('toolbar.fontFamily.serif'), checked: editorFont === 'songti' },
881
+ ],
882
+ },
883
+ {
884
+ type: 'menu', id: 'more', icon: 'settings',
885
+ items: [
886
+ { id: 'reload', label: t('toolbar.more.reload'), disabled: mode !== 'preview' },
887
+ { id: 'more-sep', label: '', separator: true },
888
+ { id: 'about', label: t('toolbar.more.about') },
889
+ ],
890
+ },
891
+ ];
892
+ }
893
+
894
+ function syncToolbar() {
895
+ if (api) api.postMessage({ type: 'setToolbar', toolbar: buildToolbar() });
896
+ }
897
+
898
+ function setMode(next) {
899
+ closePopup();
900
+ mode = next;
901
+ showPane();
902
+ syncToolbar();
903
+ if (mode === 'preview') render();
904
+ }
905
+
906
+ // ---- CodeMirror 6 Markdown editor ----
907
+
908
+ // Bridges a pasted image File to the host, which writes it under this
909
+ // mini tool's own storage dir and replies with a `finch-file://` URL to
910
+ // insert. Round-trips through the same requestId/pending-map pattern as
911
+ // saveMarkdown above, since postMessage has no built-in request/response.
912
+ function pasteImageToHost(file) {
913
+ if (!api || !api.postMessage) return Promise.reject(new Error('No host bridge.'));
914
+ return new Promise(function (resolve, reject) {
915
+ var reader = new FileReader();
916
+ reader.onload = function () {
917
+ var dataUrl = typeof reader.result === 'string' ? reader.result : '';
918
+ var comma = dataUrl.indexOf(',');
919
+ var base64 = comma >= 0 ? dataUrl.slice(comma + 1) : '';
920
+ if (!base64) { reject(new Error('Could not read pasted image.')); return; }
921
+ var id = ++pasteImageId;
922
+ pendingPasteImages[id] = { resolve: resolve, reject: reject };
923
+ setTimeout(function () {
924
+ if (pendingPasteImages[id]) { delete pendingPasteImages[id]; reject(new Error(t('status.pasteImageTimeout'))); }
925
+ }, 15000);
926
+ api.postMessage({ type: 'pasteImage', data: base64, mimeType: file.type, requestId: id });
927
+ };
928
+ reader.onerror = function () { reject(new Error('Could not read pasted image.')); };
929
+ reader.readAsDataURL(file);
930
+ });
931
+ }
932
+
933
+ var cm = window.MarkdownCodeMirror.create({
934
+ parent: editor,
935
+ value: markdown,
936
+ onChange: function (value) {
937
+ markdown = value;
938
+ closePopup();
939
+ updateEmptyState();
940
+ setDirty(true);
941
+ },
942
+ onOpenLink: function (url) {
943
+ if (api && api.postMessage) api.postMessage({ type: 'openLink', url: url });
944
+ },
945
+ onOpenImage: function (url) {
946
+ if (!api || !api.postMessage) return;
947
+ // Match Finch Delivery's preview message: pass the decoded absolute path
948
+ // directly to the host for local Finch files, rather than asking it to
949
+ // parse the custom URL a second time.
950
+ try {
951
+ var parsedImageUrl = new URL(url);
952
+ if (parsedImageUrl.protocol === 'finch-file:' && parsedImageUrl.hostname === 'local') {
953
+ var imagePath = parsedImageUrl.searchParams.get('path');
954
+ if (imagePath) { api.postMessage({ type: 'openImage', path: imagePath }); return; }
955
+ }
956
+ } catch (_) {}
957
+ api.postMessage({ type: 'openImage', url: url });
958
+ },
959
+ onPasteImage: pasteImageToHost,
960
+ });
961
+ cm.setFontSize(editorFontSize);
962
+ cm.setFontFamily(EDITOR_FONTS[editorFont]);
963
+ cm.scrollDOM.addEventListener('scroll', closePopup);
964
+
965
+ // ---- Markdown <-> bm.md rendering ----
966
+
967
+ function activeCustomCss() { return style === 'custom' ? customCss : ''; }
968
+
969
+ function render() {
970
+ if (!markdown || !api) return;
971
+ var id = ++renderId;
972
+ setStatus(t('status.rendering'));
973
+ api.postMessage({
974
+ type: 'renderBm',
975
+ markdown: markdown,
976
+ markdownStyle: style === 'custom' ? 'kami' : style,
977
+ customCss: activeCustomCss(),
978
+ requestId: id,
979
+ });
980
+ }
981
+
982
+ function captureScroll() {
983
+ var d = frame.contentDocument, e = d && d.scrollingElement;
984
+ if (!e) return null;
985
+ return { top: e.scrollTop };
986
+ }
987
+
988
+ function restoreScroll(saved) {
989
+ var d = frame.contentDocument, e = d && d.scrollingElement;
990
+ if (!saved || !e) return;
991
+ e.scrollTop = Math.min(saved.top, Math.max(0, e.scrollHeight - e.clientHeight));
992
+ }
993
+
994
+ // The <iframe id="article"> box itself already carries the correct
995
+ // theme-matched background (var(--card) in the CSS above, resolved for
996
+ // whatever skin/light/dark mode Finch is currently in). Read that back
997
+ // with getComputedStyle so the iframe's OWN document — which is what
998
+ // actually paints once its content loads — uses the identical color
999
+ // instead of the browser's default opaque-white canvas. This keeps short
1000
+ // articles from showing a plain white strip below the themed content.
1001
+ function articleBg() {
1002
+ try { return getComputedStyle(frame).backgroundColor || ''; } catch (e) { return ''; }
1003
+ }
1004
+
1005
+ function showHtml(next) {
1006
+ var previous = captureScroll();
1007
+ var d = frame.contentDocument;
1008
+ html = next;
1009
+ var bg = articleBg();
1010
+ if (frameReady && d && d.body) {
1011
+ d.body.innerHTML = html;
1012
+ if (bg) d.body.style.background = bg;
1013
+ restoreScroll(previous);
1014
+ bindPreviewSelection();
1015
+ return;
1016
+ }
1017
+ // Only reset the iframe's own html/body box model here. bm.md already
1018
+ // inlines its own spacing (e.g. "padding: 2em 1.75em") and background
1019
+ // directly onto the #bm-md section — do NOT override those, or the
1020
+ // article loses its official top padding/background and looks flush
1021
+ // against the frame edge.
1022
+ frame.srcdoc = '<!doctype html><meta charset="utf-8"><base target="_blank">' +
1023
+ '<style>html,body{margin:0!important;padding:0!important;min-height:100%}' +
1024
+ 'body{background:' + (bg || 'transparent') + '}' +
1025
+ // bm.md's own inline style on #bm-md never sets a height, so a short
1026
+ // article's background only covers its own content and the rest of
1027
+ // the frame falls back to the body background set above. That's fine
1028
+ // inside this iframe (body already carries the matching color), but
1029
+ // it means the "所见即所得" canvas/PDF export and the raw #bm-md
1030
+ // outerHTML used for copy would both crop right at the last line —
1031
+ // no full-bleed backdrop the way the actual WeChat article does once
1032
+ // published. Forcing #bm-md to at least fill the viewport keeps its
1033
+ // own background (not just the iframe's) reaching all the way down.
1034
+ '#bm-md{min-height:100dvh}</style>' +
1035
+ '<body>' + html;
1036
+ }
1037
+
1038
+ function lineRangeForOffsets(text, start, end) {
1039
+ return { start: text.slice(0, start).split('\n').length, end: text.slice(0, end).split('\n').length };
1040
+ }
1041
+
1042
+ function normalizeMappedText(text, sourceMap) {
1043
+ var normalized = '', map = [];
1044
+ for (var i = 0; i < text.length; i++) {
1045
+ if (/\s/.test(text[i])) {
1046
+ if (normalized && normalized[normalized.length - 1] !== ' ') {
1047
+ normalized += ' ';
1048
+ map.push(sourceMap[i]);
1049
+ }
1050
+ } else {
1051
+ normalized += text[i];
1052
+ map.push(sourceMap[i]);
1053
+ }
1054
+ }
1055
+ if (normalized.endsWith(' ')) { normalized = normalized.slice(0, -1); map.pop(); }
1056
+ return { text: normalized, map: map };
1057
+ }
1058
+
1059
+ // Build an approximation of bm.md's visible text while retaining a map
1060
+ // back to source offsets. Markdown markers and link destinations disappear
1061
+ // in preview, which is why a direct substring search alone misses lines.
1062
+ function projectRenderedMarkdown(source) {
1063
+ var text = '', map = [];
1064
+ function append(char, offset) { text += char; map.push(offset); }
1065
+ for (var i = 0; i < source.length;) {
1066
+ var lineStart = i === 0 || source[i - 1] === '\n';
1067
+ if (lineStart) {
1068
+ var fence = /^[ \t]*(?:```|~~~)[^\n]*/.exec(source.slice(i));
1069
+ if (fence) { i += fence[0].length; continue; }
1070
+ var prefix = /^[ \t]*(?:#{1,6}|>+|[-+*]|\d+[.)])[ \t]+/.exec(source.slice(i));
1071
+ if (prefix) { i += prefix[0].length; continue; }
1072
+ }
1073
+ var link = /^!?\[([^\]]*)\]\((?:\\.|[^)])*\)/.exec(source.slice(i));
1074
+ if (link) {
1075
+ var labelOffset = i + link[0].indexOf('[') + 1;
1076
+ for (var j = 0; j < link[1].length; j++) {
1077
+ if (!/[*_~`]/.test(link[1][j])) append(link[1][j], labelOffset + j);
1078
+ }
1079
+ i += link[0].length;
1080
+ continue;
1081
+ }
1082
+ var htmlTag = /^<[^>]+>/.exec(source.slice(i));
1083
+ if (htmlTag) { i += htmlTag[0].length; continue; }
1084
+ if (source[i] === '\\' && i + 1 < source.length) {
1085
+ append(source[i + 1], i + 1);
1086
+ i += 2;
1087
+ continue;
1088
+ }
1089
+ if (/[*_~`]/.test(source[i])) { i++; continue; }
1090
+ append(source[i], i);
1091
+ i++;
1092
+ }
1093
+ return normalizeMappedText(text, map);
1094
+ }
1095
+
1096
+ function findRenderedSourceRange(source, renderedText) {
1097
+ var direct = source.indexOf(renderedText);
1098
+ if (direct >= 0) return { start: direct, end: direct + renderedText.length };
1099
+ var projected = projectRenderedMarkdown(source);
1100
+ var needle = renderedText.replace(/\s+/g, ' ').trim();
1101
+ var index = projected.text.indexOf(needle);
1102
+ if (index >= 0) {
1103
+ return { start: projected.map[index], end: projected.map[index + needle.length - 1] + 1 };
1104
+ }
1105
+ if (needle.length >= 12) {
1106
+ var first = needle.slice(0, 6);
1107
+ var last = needle.slice(-6);
1108
+ var firstIndex = projected.text.indexOf(first);
1109
+ var lastIndex = projected.text.indexOf(last, Math.max(0, firstIndex + first.length));
1110
+ if (firstIndex >= 0 && lastIndex >= firstIndex) {
1111
+ return { start: projected.map[firstIndex], end: projected.map[lastIndex + last.length - 1] + 1 };
1112
+ }
1113
+ }
1114
+ return null;
1115
+ }
1116
+
1117
+ function pathFileName(value) {
1118
+ return String(value || '').split(/[\\/]/).pop() || '';
1119
+ }
1120
+
1121
+ function bindPreviewSelection() {
1122
+ var d = frame.contentDocument;
1123
+ if (!d || d.__bound) return;
1124
+ d.__bound = true;
1125
+ function picked() {
1126
+ if (!annotationsEnabled) return;
1127
+ var w = frame.contentWindow;
1128
+ var s = w.getSelection();
1129
+ var text = s.toString().trim();
1130
+ if (!text || !s.rangeCount) return;
1131
+ var range = s.getRangeAt(0);
1132
+ var rects = Array.from(range.getClientRects());
1133
+ var frameRect = frame.getBoundingClientRect();
1134
+ var boxes = rects.map(function (r) {
1135
+ return { top: frameRect.top + r.top, left: frameRect.left + r.left, width: r.width, height: r.height };
1136
+ });
1137
+ var rect = range.getBoundingClientRect();
1138
+ var sourceRange = findRenderedSourceRange(markdown, text);
1139
+ var lines = sourceRange ? lineRangeForOffsets(markdown, sourceRange.start, sourceRange.end) : null;
1140
+ scheduleOpenPopup(text, frameRect.top + rect.top, frameRect.top + rect.bottom,
1141
+ frameRect.left + rect.left + rect.width / 2, boxes, lines, 'preview');
1142
+ }
1143
+ d.addEventListener('mouseup', picked);
1144
+ d.addEventListener('mousedown', function () { cancelScheduledPopup(); closePopup(); });
1145
+ d.addEventListener('keyup', picked);
1146
+ // The preview lives in an iframe, so its keyboard events do not bubble to
1147
+ // the panel document. Capture Cmd+P / Ctrl+P here too, preventing the
1148
+ // browser's print dialog and returning to the editor from preview.
1149
+ d.addEventListener('keydown', function (e) {
1150
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'p' || e.key === 'P')) {
1151
+ e.preventDefault();
1152
+ setMode('edit');
1153
+ }
1154
+ });
1155
+ d.addEventListener('scroll', function () { cancelScheduledPopup(); closePopup(); }, true);
1156
+ frame.contentWindow.addEventListener('scroll', function () { cancelScheduledPopup(); closePopup(); });
1157
+ }
1158
+
1159
+ frame.onload = function () { frameReady = true; bindPreviewSelection(); };
1160
+
1161
+ // ---- Selection popup (editor + preview share this) ----
1162
+ // top/bottom describe the selection's bounding box in viewport coordinates;
1163
+ // the popup prefers sitting above the selection, and drops below it when
1164
+ // there isn't enough room above (near the top of the panel).
1165
+
1166
+ var popupDelayTimer = 0;
1167
+ var fakeSelBoxes = [];
1168
+
1169
+ // Selection popups appear after a short delay so a still-in-progress drag
1170
+ // selection doesn't flash the popup at every intermediate position.
1171
+ function scheduleOpenPopup(text, top, bottom, centerX, boxes, lines, origin) {
1172
+ cancelScheduledPopup();
1173
+ popupDelayTimer = setTimeout(function () {
1174
+ openPopup(text, top, bottom, centerX, boxes, lines, origin);
1175
+ }, 300);
1176
+ }
1177
+
1178
+ function cancelScheduledPopup() {
1179
+ if (popupDelayTimer) { clearTimeout(popupDelayTimer); popupDelayTimer = 0; }
1180
+ }
1181
+
1182
+ function openPopup(text, top, bottom, centerX, boxes, lines, origin) {
1183
+ selection.text = text;
1184
+ selection.lines = lines || null;
1185
+ selection.origin = origin || '';
1186
+ popup.classList.remove('hidden');
1187
+ popupInput.value = '';
1188
+ showFakeSelection(boxes);
1189
+ requestAnimationFrame(function () {
1190
+ positionPopup(top, bottom, centerX);
1191
+ popupInput.focus();
1192
+ });
1193
+ }
1194
+
1195
+ // Anchors the popup to the end-top of the selection (cursor) position:
1196
+ // its left edge lines up with the cursor x and it opens above when
1197
+ // there's room, so the popup sits above-right of where you stopped
1198
+ // selecting, not floating centered over (or to the left of) it.
1199
+ function positionPopup(top, bottom, cursorX) {
1200
+ var margin = 8;
1201
+ var w = popup.offsetWidth || 300;
1202
+ var h = popup.offsetHeight || 44;
1203
+ var left = Math.max(margin, Math.min(cursorX, window.innerWidth - w - margin));
1204
+ var y = (top - margin >= h + 10) ? (top - h - 10) : (bottom + 10);
1205
+ y = Math.max(margin, Math.min(y, window.innerHeight - h - margin));
1206
+ popup.style.left = left + 'px';
1207
+ popup.style.top = y + 'px';
1208
+ }
1209
+
1210
+ // iframe selection highlighting disappears when focus moves to the popup.
1211
+ // Preview selections therefore use temporary fixed boxes. CodeMirror draws
1212
+ // its own persistent selection layer and does not use this fallback.
1213
+ function showFakeSelection(boxes) {
1214
+ clearFakeSelection();
1215
+ (boxes || []).forEach(function (b) {
1216
+ var el = document.createElement('div');
1217
+ el.className = 'fake-sel';
1218
+ el.style.top = b.top + 'px';
1219
+ el.style.left = b.left + 'px';
1220
+ el.style.width = b.width + 'px';
1221
+ el.style.height = b.height + 'px';
1222
+ document.body.appendChild(el);
1223
+ fakeSelBoxes.push(el);
1224
+ });
1225
+ }
1226
+
1227
+ function clearFakeSelection() {
1228
+ fakeSelBoxes.forEach(function (el) { el.remove(); });
1229
+ fakeSelBoxes = [];
1230
+ }
1231
+
1232
+ function closePopup(restoreEditorFocus) {
1233
+ var shouldRestore = restoreEditorFocus === true && selection.origin === 'editor' && mode === 'edit';
1234
+ cancelScheduledPopup();
1235
+ popup.classList.add('hidden');
1236
+ selection.text = '';
1237
+ selection.lines = null;
1238
+ selection.origin = '';
1239
+ clearFakeSelection();
1240
+ if (shouldRestore) requestAnimationFrame(function () { cm.focus(); });
1241
+ }
1242
+
1243
+ popupSend.onclick = submitAnnotation;
1244
+ popupInput.addEventListener('keydown', function (e) {
1245
+ if (e.key === 'Enter') submitAnnotation();
1246
+ if (e.key === 'Escape') closePopup(true);
1247
+ });
1248
+ document.addEventListener('keydown', function (e) {
1249
+ if (e.key === 'Escape' && !popup.classList.contains('hidden')) closePopup(true);
1250
+ });
1251
+ // Clicking anywhere outside the popup cancels the pending/open popup and
1252
+ // drops the fake selection highlight. The editor and toolbar are part of
1253
+ // this same top document so a single listener covers them; the preview
1254
+ // iframe is a separate browsing context and gets its own mousedown
1255
+ // listener in bindPreviewSelection().
1256
+ document.addEventListener('mousedown', function (e) {
1257
+ if (popup.contains(e.target)) return;
1258
+ cancelScheduledPopup();
1259
+ closePopup();
1260
+ });
1261
+ window.addEventListener('resize', closePopup);
1262
+
1263
+ function lineLabel() {
1264
+ if (!selection.lines) return t('annotate.lineUnknown');
1265
+ var a = selection.lines.start, b = selection.lines.end;
1266
+ return a === b ? t('annotate.lineSingle', { a: a }) : t('annotate.lineRange', { a: a, b: b });
1267
+ }
1268
+
1269
+ function lineLabelCompact() {
1270
+ if (!selection.lines) return t('annotate.lineCompactUnknown');
1271
+ return selection.lines.start + '-' + selection.lines.end;
1272
+ }
1273
+
1274
+ async function submitAnnotation() {
1275
+ var comment = popupInput.value.trim();
1276
+ if (!selection.text || !api || !api.composer) return;
1277
+ try {
1278
+ await api.composer.addContexts([{
1279
+ type: 'annotation',
1280
+ label: (fileName || 'Markdown') + ':' + lineLabelCompact(),
1281
+ note: comment || undefined,
1282
+ promptText: t('annotate.promptText', {
1283
+ lineLabel: lineLabel(),
1284
+ requirement: comment ? t('annotate.requirementWith', { comment: comment }) : t('annotate.requirementDefault'),
1285
+ text: selection.text,
1286
+ path: sourcePath || t('annotate.pathMissing'),
1287
+ }),
1288
+ reminder: t('annotate.reminder'),
1289
+ }]);
1290
+ closePopup(true);
1291
+ setStatus(t('annotate.added'));
1292
+ } catch (e) {
1293
+ setStatus(String(e), true);
1294
+ }
1295
+ }
1296
+
1297
+ // ---- Editor selection ----
1298
+
1299
+ function offerEditorSelection() {
1300
+ if (!annotationsEnabled) return;
1301
+ var picked = cm.getSelection();
1302
+ if (!picked) return;
1303
+ var text = picked.text.trim();
1304
+ if (!text) return;
1305
+ var lines = lineRangeForOffsets(cm.getValue(), picked.start, picked.end);
1306
+ scheduleOpenPopup(text, picked.rect.top, picked.rect.bottom,
1307
+ (picked.rect.left + picked.rect.right) / 2, null, lines, 'editor');
1308
+ }
1309
+
1310
+ editor.addEventListener('mouseup', offerEditorSelection);
1311
+ editor.addEventListener('keyup', function (e) {
1312
+ if (e.shiftKey) offerEditorSelection();
1313
+ });
1314
+
1315
+ // ---- Save to disk: prefer the File System Access handle (works even when the
1316
+ // host process can't expose an absolute path), fall back to the backend write
1317
+ // when we have a real sourcePath, otherwise tell the user how to connect one.
1318
+ // Saving is explicit (Save button / Cmd|Ctrl+S) rather than automatic, so the
1319
+ // user is always in control of what's actually on disk. ----
1320
+
1321
+ var dirty = false;
1322
+ var savedFlash = false; // true for ~3s right after a successful save, drives the save-check icon
1323
+
1324
+ function setDirty(next, justSaved) {
1325
+ dirty = next;
1326
+ if (savedFlashTimer) { clearTimeout(savedFlashTimer); savedFlashTimer = 0; }
1327
+ savedFlash = !dirty && !!justSaved;
1328
+ syncToolbar();
1329
+ if (!dirty && justSaved) {
1330
+ savedFlashTimer = setTimeout(function () {
1331
+ savedFlashTimer = 0;
1332
+ if (!dirty) { savedFlash = false; syncToolbar(); }
1333
+ }, 3000);
1334
+ }
1335
+ }
1336
+
1337
+ async function writeToDisk(content) {
1338
+ if (fileHandle && fileHandle.createWritable) {
1339
+ try {
1340
+ var writable = await fileHandle.createWritable();
1341
+ await writable.write(content);
1342
+ await writable.close();
1343
+ lastModified = (await fileHandle.getFile()).lastModified;
1344
+ savedMarkdown = content;
1345
+ setDirty(markdown !== content, markdown === content);
1346
+ setStatus(markdown === content ? t('status.saved') : t('status.savedWithNewChanges'));
1347
+ return true;
1348
+ } catch (e) {
1349
+ setStatus(t('status.saveFailed', { err: String(e) }), true);
1350
+ return false;
1351
+ }
1352
+ }
1353
+ if (sourcePath) {
1354
+ var id = ++saveId;
1355
+ setStatus(t('status.saving'));
1356
+ pendingSaveContents[id] = content;
1357
+ api.postMessage({ type: 'saveMarkdown', path: sourcePath, markdown: content, requestId: id });
1358
+ // The actual result arrives later as a 'savedMarkdown' (or 'error')
1359
+ // message; resolve this promise then so awaiters (e.g. the unsaved-
1360
+ // changes confirm) see the real outcome instead of an optimistic true.
1361
+ return new Promise(function (resolve) { pendingSaveResolvers[id] = resolve; });
1362
+ }
1363
+ setStatus(t('status.noSourceFile'), true);
1364
+ return false;
1365
+ }
1366
+
1367
+ document.addEventListener('keydown', function (e) {
1368
+ var mod = e.metaKey || e.ctrlKey;
1369
+ if (mod && (e.key === 's' || e.key === 'S')) {
1370
+ e.preventDefault();
1371
+ saveNow();
1372
+ return;
1373
+ }
1374
+ // Reserve Cmd+P / Ctrl+P for Markdown preview rather than browser print.
1375
+ if (mod && (e.key === 'p' || e.key === 'P')) {
1376
+ e.preventDefault();
1377
+ setMode(mode === 'edit' ? 'preview' : 'edit');
1378
+ }
1379
+ });
1380
+
1381
+ // ---- Open / watch source file ----
1382
+
1383
+ function stopWatching() {
1384
+ if (watchTimer) { clearInterval(watchTimer); watchTimer = 0; }
1385
+ fileHandle = null;
1386
+ }
1387
+
1388
+ function applyDocument(nextMarkdown, nextName, nextPath, force) {
1389
+ if (!force && dirty && cm.hasFocus() && savedMarkdown !== null && nextMarkdown !== savedMarkdown) {
1390
+ setStatus(t('status.externalUpdateSkipped'), true);
1391
+ return false;
1392
+ }
1393
+ markdown = nextMarkdown;
1394
+ name = nextName;
1395
+ if (nextPath) sourcePath = nextPath;
1396
+ fileName = pathFileName(nextPath || sourcePath) || nextName || 'Markdown';
1397
+ savedMarkdown = markdown;
1398
+ cm.setValue(markdown);
1399
+ setDirty(false); // also rebuilds+syncs the whole toolbar, picking up the new sourcePath/fileName below
1400
+ setPanelTitle(fileName);
1401
+ updateEmptyState();
1402
+ if (mode === 'preview') render();
1403
+ return true;
1404
+ }
1405
+
1406
+ async function loadFile(f) {
1407
+ name = f.name;
1408
+ lastModified = f.lastModified;
1409
+ if (typeof f.path === 'string' && f.path) {
1410
+ sourcePath = f.path;
1411
+ if (api) api.postMessage({ type: 'watchPath', path: sourcePath });
1412
+ } else {
1413
+ sourcePath = '';
1414
+ }
1415
+ // Land in the editor, not the rendered preview — opening a file is about
1416
+ // reading/editing it, the user switches to preview explicitly when ready.
1417
+ applyDocument(await f.text(), name, sourcePath, true);
1418
+ }
1419
+
1420
+ function watchFile() {
1421
+ if (!fileHandle) return;
1422
+ if (watchTimer) clearInterval(watchTimer);
1423
+ watchTimer = setInterval(async function () {
1424
+ if (watchBusy || !fileHandle) return;
1425
+ watchBusy = true;
1426
+ try {
1427
+ var next = await fileHandle.getFile();
1428
+ if (next.lastModified !== lastModified) {
1429
+ lastModified = next.lastModified;
1430
+ var text = await next.text();
1431
+ if (text !== savedMarkdown && applyDocument(text, name, sourcePath)) {
1432
+ setStatus(t('status.externalUpdateApplied'));
1433
+ }
1434
+ }
1435
+ } catch (e) { /* ignore */ }
1436
+ watchBusy = false;
1437
+ }, 800);
1438
+ }
1439
+
1440
+ var nativePickPending = false;
1441
+ var nativePickTimer = 0;
1442
+
1443
+ function clearNativePickTimer() {
1444
+ if (nativePickTimer) { clearTimeout(nativePickTimer); nativePickTimer = 0; }
1445
+ }
1446
+
1447
+ // The Open button is disabled for as long as `nativePickPending` is true
1448
+ // (see buildToolbar), so the user cannot click their way out of a hung
1449
+ // native request the way they normally could — the click itself is what's
1450
+ // blocked. If the backend's reply is ever lost (observed after repeatedly
1451
+ // switching Sessions while this panel is open, which can make a postMessage
1452
+ // back to this panel fail silently mid-transition), the button would
1453
+ // otherwise stay disabled forever with no way to recover. This timer is a
1454
+ // pure safety net: it does not touch any real native dialog (the backend
1455
+ // itself deliberately has no timeout on ctx.ui.pickFile(), see 'requestOpen'
1456
+ // in index.ts) — it only un-sticks *this panel's own UI state* so the next
1457
+ // click can try again.
1458
+ function armNativePickTimer() {
1459
+ clearNativePickTimer();
1460
+ nativePickTimer = setTimeout(function () {
1461
+ nativePickTimer = 0;
1462
+ if (!nativePickPending) return;
1463
+ nativePickPending = false;
1464
+ pickFileSupported = false; // stop trusting a bridge that just went silent this session
1465
+ syncToolbar();
1466
+ setStatus(t('status.pickerTimeout'), true);
1467
+ }, 20000);
1468
+ }
1469
+
1470
+ async function openFile() {
1471
+ stopWatching();
1472
+ if (pickFileSupported && api && api.postMessage) {
1473
+ if (nativePickPending) {
1474
+ // A previous native request is still hanging (host round-trip stuck).
1475
+ // Don't stack another one silently — this click is a fresh, real user
1476
+ // gesture, so use it to open the reliable in-page/browser picker
1477
+ // immediately instead of leaving the button looking unresponsive.
1478
+ clearNativePickTimer();
1479
+ pickFileSupported = false;
1480
+ nativePickPending = false;
1481
+ syncToolbar();
1482
+ await openFileBrowserFallback();
1483
+ return;
1484
+ }
1485
+ // Native Finch file picker: browses the current Space/workspace directory
1486
+ // tree with real absolute paths, no Electron `File.path` shim needed.
1487
+ nativePickPending = true;
1488
+ armNativePickTimer();
1489
+ syncToolbar();
1490
+ setStatus(t('status.openingPicker'));
1491
+ api.postMessage({ type: 'requestOpen' });
1492
+ return;
1493
+ }
1494
+ await openFileBrowserFallback();
1495
+ }
1496
+
1497
+ async function openFileBrowserFallback() {
1498
+ if ('showOpenFilePicker' in window) {
1499
+ try {
1500
+ var handles = await window.showOpenFilePicker({
1501
+ types: [{ description: 'Markdown', accept: { 'text/markdown': ['.md', '.markdown'] } }],
1502
+ multiple: false,
1503
+ });
1504
+ fileHandle = handles[0];
1505
+ await loadFile(await fileHandle.getFile());
1506
+ watchFile();
1507
+ return;
1508
+ } catch (e) {
1509
+ if (e && e.name === 'AbortError') return;
1510
+ }
1511
+ }
1512
+ var input = document.createElement('input');
1513
+ input.type = 'file';
1514
+ input.accept = '.md,.markdown,text/markdown';
1515
+ input.onchange = function () {
1516
+ var f = input.files && input.files[0];
1517
+ if (f) loadFile(f).catch(function (e) { setStatus(String(e), true); });
1518
+ };
1519
+ input.click();
1520
+ }
1521
+
1522
+ // ---- Copy HTML / manual save ----
1523
+
1524
+ // Toolbar buttons are host-chrome UI: the click reaches this panel only
1525
+ // through a postMessage round-trip, and by the time it does the panel's own
1526
+ // document has often lost focus. The async Clipboard API refuses to write
1527
+ // ("Document is not focused") in that state. Reclaim focus first, and if the
1528
+ // async API still won't cooperate, fall back to the older selection-based
1529
+ // execCommand('copy').
1530
+ //
1531
+ // Both paths deliberately stay in the TOP document, never the preview
1532
+ // <iframe>: the iframe is `sandbox="allow-same-origin"` with no
1533
+ // `allow-scripts`, and some hosts silently refuse focus()/execCommand
1534
+ // targeted at a sandboxed same-origin document even when called from the
1535
+ // (unsandboxed) parent — which was the actual cause of "复制到公众号"
1536
+ // failing intermittently. Copying an equivalent holder element in the top
1537
+ // document sidesteps that entirely.
1538
+ function focusCopySurface() {
1539
+ window.focus();
1540
+ document.body.tabIndex = -1;
1541
+ try { document.body.focus({ preventScroll: true }); } catch (e) { document.body.focus(); }
1542
+ }
1543
+
1544
+ // Poll for document focus for a short while instead of a single
1545
+ // best-effort attempt: the 'finch:menu' click reaches us via a postMessage
1546
+ // round-trip from the host toolbar, and on some platforms the webview
1547
+ // hasn't actually regained input focus yet by the time that message
1548
+ // arrives — a single focus() + two rAFs isn't always enough. Re-attempt a
1549
+ // few times with short waits; this costs at most ~250ms and is invisible
1550
+ // to the user, but meaningfully reduces spurious "剪贴板不可用" failures.
1551
+ async function waitForCopyFocus(maxAttempts) {
1552
+ for (var i = 0; i < maxAttempts; i++) {
1553
+ focusCopySurface();
1554
+ await new Promise(function (resolve) {
1555
+ requestAnimationFrame(function () { requestAnimationFrame(resolve); });
1556
+ });
1557
+ if (document.hasFocus()) return true;
1558
+ await new Promise(function (resolve) { setTimeout(resolve, 40); });
1559
+ }
1560
+ return document.hasFocus();
1561
+ }
1562
+
1563
+ // Ask the host to turn only its own finch-file image assets into data:
1564
+ // URLs. The saved Markdown and preview HTML retain finch-file URLs; this
1565
+ // expanded representation exists solely in the outbound clipboard payload,
1566
+ // where WeChat has no way to resolve Finch's private local protocol.
1567
+ function prepareClipboardHtml(sourceHtml) {
1568
+ var urls = Array.from(new Set(sourceHtml.match(/finch-file:\/\/local\?path=[^\s)"']+/g) || []));
1569
+ if (!urls.length || !api || !api.postMessage) return Promise.resolve(sourceHtml);
1570
+ return new Promise(function (resolve, reject) {
1571
+ var id = ++clipboardImageId;
1572
+ pendingClipboardImages[id] = { resolve: resolve, reject: reject, sourceHtml: sourceHtml };
1573
+ setTimeout(function () {
1574
+ if (pendingClipboardImages[id]) { delete pendingClipboardImages[id]; reject(new Error(t('status.clipboardPrepareTimeout'))); }
1575
+ }, 15000);
1576
+ api.postMessage({ type: 'readClipboardImages', urls: urls, requestId: id });
1577
+ });
1578
+ }
1579
+
1580
+ // bmmd can leave an empty <p> directly before/after an image <figure>.
1581
+ // The WeChat editor normalizes it to
1582
+ // `<p><span leaf><br class="ProseMirror-trailingBreak"></span></p>`, which
1583
+ // becomes a visible blank line above/below the pasted image. Remove ONLY
1584
+ // those adjacent empty paragraphs from the clipboard copy. Do not touch
1585
+ // the figure's own margins or its figcaption — both are intentional article
1586
+ // formatting, including the automatic "Pasted image" caption.
1587
+ function compactClipboardImageLayout(clipboardHtml) {
1588
+ var doc = new DOMParser().parseFromString(clipboardHtml, 'text/html');
1589
+ function isEmptyParagraph(node) {
1590
+ return node && node.tagName === 'P' && !node.textContent.trim() && !node.querySelector('img,video,iframe,table');
1591
+ }
1592
+ Array.from(doc.querySelectorAll('figure.figure-image')).forEach(function (figure) {
1593
+ var before = figure.previousElementSibling;
1594
+ var after = figure.nextElementSibling;
1595
+ if (isEmptyParagraph(before)) before.remove();
1596
+ if (isEmptyParagraph(after)) after.remove();
1597
+ });
1598
+ return doc.body.innerHTML;
1599
+ }
1600
+
1601
+ async function copyRichText(htmlValue, plainStr) {
1602
+ // Fast path for the in-page action bar: the click already happened in
1603
+ // this document, so focus is real and the write can go straight out.
1604
+ // Burning ~250ms in the focus poll below would only risk the transient
1605
+ // user-activation window for no benefit.
1606
+ if (!document.hasFocus()) await waitForCopyFocus(5);
1607
+ // `text/html` deliberately receives a Promise<Blob>: clipboard.write()
1608
+ // itself starts immediately while the in-page click's user activation is
1609
+ // fresh, while its promise concurrently waits for the host to read and
1610
+ // embed local image assets. Awaiting that host round-trip *before* write
1611
+ // would make Chromium reject the clipboard request as no longer gestural.
1612
+ if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
1613
+ try {
1614
+ await navigator.clipboard.write([new ClipboardItem({
1615
+ 'text/html': Promise.resolve(htmlValue).then(function (value) { return new Blob([value], { type: 'text/html' }); }),
1616
+ 'text/plain': new Blob([plainStr], { type: 'text/plain' }),
1617
+ })]);
1618
+ return 'rich';
1619
+ } catch (e) { /* fall through to the legacy path below */ }
1620
+ }
1621
+ var htmlStr = await Promise.resolve(htmlValue);
1622
+ if (copyRichTextLegacy(htmlStr)) return 'rich';
1623
+ // Both rich-text paths failed (most likely: the webview never actually
1624
+ // regained real input focus, which both APIs require). Rather than
1625
+ // surfacing a hard failure, degrade to a plain-text copy so the user
1626
+ // still gets *something* usable on the clipboard.
1627
+ if (copyPlainTextLegacy(plainStr)) return 'plain';
1628
+ return 'none';
1629
+ }
1630
+
1631
+ function copyRichTextLegacy(htmlStr) {
1632
+ var holder = document.createElement('div');
1633
+ holder.contentEditable = 'true';
1634
+ holder.style.position = 'fixed';
1635
+ holder.style.left = '-9999px';
1636
+ holder.style.top = '0';
1637
+ holder.innerHTML = htmlStr;
1638
+ document.body.appendChild(holder);
1639
+ var range = document.createRange();
1640
+ range.selectNodeContents(holder);
1641
+ var sel = window.getSelection();
1642
+ sel.removeAllRanges();
1643
+ sel.addRange(range);
1644
+ var ok = false;
1645
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
1646
+ sel.removeAllRanges();
1647
+ holder.remove();
1648
+ return ok;
1649
+ }
1650
+
1651
+ function copyPlainTextLegacy(plainStr) {
1652
+ var ta = document.createElement('textarea');
1653
+ ta.value = plainStr;
1654
+ ta.style.position = 'fixed';
1655
+ ta.style.left = '-9999px';
1656
+ ta.style.top = '0';
1657
+ document.body.appendChild(ta);
1658
+ ta.focus();
1659
+ ta.select();
1660
+ var ok = false;
1661
+ try { ok = document.execCommand('copy'); } catch (e) { ok = false; }
1662
+ ta.remove();
1663
+ return ok;
1664
+ }
1665
+
1666
+ // A previous attempt here walked the live iframe DOM and rewrote every
1667
+ // element's font-family/color/background-color from computed values,
1668
+ // plus converted top-level children's vertical margins into padding to
1669
+ // avoid perceived "gaps" — all based on a theory that WeChat's paste
1670
+ // cleaner drops the #bm-md root's own style attribute. Actual DevTools
1671
+ // inspection of the pasted result proved that theory wrong: the pasted
1672
+ // <section id="bm-md" style="...background-color...padding...font-
1673
+ // family..."> keeps its full inline style completely intact. The
1674
+ // flattening pass was therefore not just unnecessary but actively
1675
+ // harmful — e.g. it force-added the container's vertical margin as
1676
+ // *padding* onto every direct child including <hr>, which already had
1677
+ // its own explicit background-color; padding on an <hr> paints INSIDE
1678
+ // that background, so a 1px divider line turned into a tall solid
1679
+ // block, which read as "the divider disappeared". Match bm.md's own
1680
+ // "复制到公众号" behavior exactly for all ordinary content. The only
1681
+ // intentional exception is local pasted images: Finch's private
1682
+ // `finch-file:` protocol cannot exist outside Finch, so their `src` is
1683
+ // converted into a data URL in the clipboard-only copy (see
1684
+ // prepareClipboardHtml); preview/source remain untouched.
1685
+ async function copyHtml() {
1686
+ if (!html) { setStatus(t('status.pleaseOpenArticle'), true); return; }
1687
+ try {
1688
+ var plain = frame.contentDocument && frame.contentDocument.body
1689
+ ? frame.contentDocument.body.innerText
1690
+ : html.replace(/<[^>]+>/g, '');
1691
+ var result = await copyRichText(prepareClipboardHtml(html), plain);
1692
+ if (result === 'none') {
1693
+ // One more attempt after a longer settle: covers the case where
1694
+ // the webview genuinely hadn't regained input focus yet on the
1695
+ // first try (e.g. right after switching sessions/tabs).
1696
+ await new Promise(function (resolve) { setTimeout(resolve, 200); });
1697
+ result = await copyRichText(prepareClipboardHtml(html), plain);
1698
+ }
1699
+ if (result === 'rich') setStatus(t('status.copiedRich'));
1700
+ else if (result === 'plain') setStatus(t('status.copiedPlainOnly'), true);
1701
+ else setStatus(t('status.copyFailed'), true);
1702
+ } catch (e) {
1703
+ setStatus(t('status.richCopyFailed', { err: String(e) }), true);
1704
+ } finally {
1705
+ if (mode === 'edit') requestAnimationFrame(function () { cm.focus(); });
1706
+ }
1707
+ }
1708
+
1709
+ // ---- Export (copy/download image, download PDF) ----
1710
+ //
1711
+ // There's no headless-render or html2canvas-style library bundled here, so
1712
+ // this hand-rolls the same trick browsers themselves use for that purpose:
1713
+ // serialize the *live rendered* #bm-md node (already fully styled — same
1714
+ // DOM `copyHtml()` no longer needs to touch) into an SVG <foreignObject>,
1715
+ // rasterize that through an <img>, then draw it onto a <canvas>. Because
1716
+ // the source is the live iframe DOM rather than the static `html` string,
1717
+ // this captures it pixel-for-pixel identical to what's on screen —
1718
+ // genuinely "所见即所得" — with no separate style-flattening pass to get
1719
+ // subtly wrong.
1720
+ function articleSize() {
1721
+ if (!frameReady || !frame.contentDocument) return null;
1722
+ var root = frame.contentDocument.querySelector('#bm-md');
1723
+ if (!root) return null;
1724
+ var rect = root.getBoundingClientRect();
1725
+ return {
1726
+ root: root,
1727
+ width: Math.ceil(rect.width || root.scrollWidth),
1728
+ height: Math.ceil(rect.height || root.scrollHeight),
1729
+ };
1730
+ }
1731
+
1732
+ function renderArticleCanvas() {
1733
+ var info = articleSize();
1734
+ if (!info || !info.width || !info.height) return Promise.resolve(null);
1735
+ var scale = 2; // fixed 2x for crisp copy/export regardless of display DPR
1736
+
1737
+ // An <img> pointed at an SVG data URL parses that SVG as XML, so the
1738
+ // markup inside <foreignObject> has to be well-formed XHTML. The HTML
1739
+ // serializer behind `.outerHTML` happily emits void elements as `<br>`
1740
+ // or `<img …>` with no self-closing slash, which is valid HTML but
1741
+ // fatal XML — the parse aborts and the <img> fires `error`, which is
1742
+ // exactly why copying/downloading the image silently failed. Serialize
1743
+ // through XMLSerializer instead so every tag is properly closed.
1744
+ var clone = info.root.cloneNode(true);
1745
+ // The live node gets `min-height:100dvh` from the preview stylesheet,
1746
+ // which does not travel with the clone (no stylesheet inside the SVG)
1747
+ // — restore the measured height inline so the exported image keeps the
1748
+ // same full-bleed background the preview shows.
1749
+ clone.style.minHeight = info.height + 'px';
1750
+ clone.style.margin = '0';
1751
+ clone.style.boxSizing = 'border-box';
1752
+ var serialized;
1753
+ try {
1754
+ serialized = new XMLSerializer().serializeToString(clone);
1755
+ } catch (e) {
1756
+ return Promise.reject(new Error(t('status.serializeFailed', { err: String(e) })));
1757
+ }
1758
+ var svgMarkup = '<svg xmlns="http://www.w3.org/2000/svg" width="' + info.width + '" height="' + info.height + '">' +
1759
+ '<foreignObject x="0" y="0" width="' + info.width + '" height="' + info.height + '">' +
1760
+ serialized +
1761
+ '</foreignObject></svg>';
1762
+ var svgUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgMarkup);
1763
+ return new Promise(function (resolve, reject) {
1764
+ var img = new Image();
1765
+ img.decoding = 'sync';
1766
+ img.onload = function () {
1767
+ try {
1768
+ var canvas = document.createElement('canvas');
1769
+ canvas.width = Math.round(info.width * scale);
1770
+ canvas.height = Math.round(info.height * scale);
1771
+ var ctx2d = canvas.getContext('2d');
1772
+ ctx2d.scale(scale, scale);
1773
+ // foreignObject content is transparent where the article doesn't
1774
+ // paint; fill with the article's own background first so JPEG
1775
+ // (which has no alpha) never turns those pixels black.
1776
+ var bg = '';
1777
+ try { bg = frame.contentWindow.getComputedStyle(info.root).backgroundColor; } catch (e2) { bg = ''; }
1778
+ if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
1779
+ ctx2d.fillStyle = bg;
1780
+ ctx2d.fillRect(0, 0, info.width, info.height);
1781
+ }
1782
+ ctx2d.drawImage(img, 0, 0, info.width, info.height);
1783
+ resolve({ canvas: canvas, cssWidth: info.width, cssHeight: info.height });
1784
+ } catch (e) { reject(e); }
1785
+ };
1786
+ img.onerror = function () {
1787
+ reject(new Error(t('status.imageConversionFailed')));
1788
+ };
1789
+ img.src = svgUrl;
1790
+ });
1791
+ }
1792
+
1793
+ function canvasToBlob(canvas, type, quality) {
1794
+ return new Promise(function (resolve, reject) {
1795
+ canvas.toBlob(function (blob) {
1796
+ if (blob) resolve(blob); else reject(new Error(t('status.imageGenFailed')));
1797
+ }, type, quality);
1798
+ });
1799
+ }
1800
+
1801
+ function bytesToBase64(bytes) {
1802
+ var binary = '';
1803
+ var chunk = 0x8000;
1804
+ for (var i = 0; i < bytes.length; i += chunk) {
1805
+ binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
1806
+ }
1807
+ return btoa(binary);
1808
+ }
1809
+
1810
+ async function copyImage() {
1811
+ if (!html) { setStatus(t('status.pleaseOpenArticle'), true); return; }
1812
+ if (!window.ClipboardItem || !navigator.clipboard || !navigator.clipboard.write) {
1813
+ setStatus(t('status.clipboardUnsupported'), true);
1814
+ return;
1815
+ }
1816
+ setStatus(t('status.generatingImage'));
1817
+ // Hand ClipboardItem a *pending promise* rather than a finished Blob, so
1818
+ // clipboard.write() is invoked immediately while the click's transient
1819
+ // user activation is still fresh; the rasterization then resolves into
1820
+ // it. Awaiting the blob first would spend that activation on rendering
1821
+ // and can get the write rejected on slower/longer articles.
1822
+ var blobPromise = renderArticleCanvas().then(function (made) {
1823
+ if (!made) throw new Error(t('status.previewNotReady'));
1824
+ return canvasToBlob(made.canvas, 'image/png');
1825
+ });
1826
+ try {
1827
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blobPromise })]);
1828
+ setStatus(t('status.imageCopied'));
1829
+ return;
1830
+ } catch (e) {
1831
+ // Some builds reject a promise-valued ClipboardItem — retry once with
1832
+ // the resolved Blob before giving up.
1833
+ try {
1834
+ var blob = await blobPromise;
1835
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
1836
+ setStatus(t('status.imageCopied'));
1837
+ return;
1838
+ } catch (e2) {
1839
+ setStatus(t('status.copyImageFailed', { err: e2 && e2.message ? e2.message : String(e2) }), true);
1840
+ }
1841
+ }
1842
+ }
1843
+
1844
+ async function exportImage() {
1845
+ if (!html) { setStatus(t('status.pleaseOpenArticle'), true); return; }
1846
+ try {
1847
+ setStatus(t('status.generatingImage'));
1848
+ var made = await renderArticleCanvas();
1849
+ if (!made) { setStatus(t('status.exportImageFailedGeneric'), true); return; }
1850
+ var blob = await canvasToBlob(made.canvas, 'image/png');
1851
+ var buf = await blob.arrayBuffer();
1852
+ var base64 = bytesToBase64(new Uint8Array(buf));
1853
+ if (api && api.postMessage) {
1854
+ api.postMessage({ type: 'exportFile', data: base64, ext: 'png', path: sourcePath, fileName: fileName, markdown: markdown });
1855
+ }
1856
+ } catch (e) {
1857
+ setStatus(t('status.exportImageFailed', { err: String(e) }), true);
1858
+ }
1859
+ }
1860
+
1861
+ // Minimal single-page, single-image PDF, hand-built without any PDF
1862
+ // library: a JPEG can be embedded byte-for-byte as a /DCTDecode image
1863
+ // XObject stream, so no re-encoding is needed beyond what canvas.toBlob
1864
+ // already produced. Page size is the article's CSS pixel size converted
1865
+ // to points (1px = 0.75pt @ 96dpi); the image itself keeps its full
1866
+ // (2x-scaled) pixel resolution for sharpness.
1867
+ function pad10(n) {
1868
+ var s = String(n);
1869
+ while (s.length < 10) s = '0' + s;
1870
+ return s;
1871
+ }
1872
+
1873
+ function strToBytes(str) {
1874
+ var bytes = new Uint8Array(str.length);
1875
+ for (var i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff;
1876
+ return bytes;
1877
+ }
1878
+
1879
+ function concatBytes(parts) {
1880
+ var total = 0;
1881
+ for (var i = 0; i < parts.length; i++) total += parts[i].length;
1882
+ var out = new Uint8Array(total);
1883
+ var offset = 0;
1884
+ for (var i = 0; i < parts.length; i++) { out.set(parts[i], offset); offset += parts[i].length; }
1885
+ return out;
1886
+ }
1887
+
1888
+ function buildSingleImagePdf(jpegBytes, imgW, imgH, pageW, pageH) {
1889
+ var header = strToBytes('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
1890
+ var parts = [header];
1891
+ var offsets = [0];
1892
+ var pos = header.length;
1893
+ function addObj(bytesArr) {
1894
+ offsets.push(pos);
1895
+ parts.push(bytesArr);
1896
+ pos += bytesArr.length;
1897
+ }
1898
+ addObj(strToBytes('1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n'));
1899
+ addObj(strToBytes('2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n'));
1900
+ addObj(strToBytes('3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + pageW.toFixed(2) + ' ' + pageH.toFixed(2) + '] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\nendobj\n'));
1901
+ var imgHeader = strToBytes('4 0 obj\n<< /Type /XObject /Subtype /Image /Width ' + imgW + ' /Height ' + imgH + ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ' + jpegBytes.length + ' >>\nstream\n');
1902
+ var imgFooter = strToBytes('\nendstream\nendobj\n');
1903
+ addObj(concatBytes([imgHeader, jpegBytes, imgFooter]));
1904
+ var contentStream = 'q ' + pageW.toFixed(2) + ' 0 0 ' + pageH.toFixed(2) + ' 0 0 cm /Im0 Do Q';
1905
+ var csBytes = strToBytes(contentStream);
1906
+ addObj(concatBytes([strToBytes('5 0 obj\n<< /Length ' + csBytes.length + ' >>\nstream\n'), csBytes, strToBytes('\nendstream\nendobj\n')]));
1907
+ var xrefStart = pos;
1908
+ var xrefLines = ['xref\n0 6\n0000000000 65535 f \n'];
1909
+ for (var i = 1; i <= 5; i++) xrefLines.push(pad10(offsets[i]) + ' 00000 n \n');
1910
+ var trailer = 'trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n' + xrefStart + '\n%%EOF';
1911
+ parts.push(strToBytes(xrefLines.join('') + trailer));
1912
+ return concatBytes(parts);
1913
+ }
1914
+
1915
+ async function exportPdf() {
1916
+ if (!html) { setStatus(t('status.pleaseOpenArticle'), true); return; }
1917
+ try {
1918
+ setStatus(t('status.generatingPdf'));
1919
+ var made = await renderArticleCanvas();
1920
+ if (!made) { setStatus(t('status.exportPdfFailedGeneric'), true); return; }
1921
+ var blob = await canvasToBlob(made.canvas, 'image/jpeg', 0.92);
1922
+ var buf = await blob.arrayBuffer();
1923
+ var jpegBytes = new Uint8Array(buf);
1924
+ var pageW = made.cssWidth * 0.75;
1925
+ var pageH = made.cssHeight * 0.75;
1926
+ var pdfBytes = buildSingleImagePdf(jpegBytes, made.canvas.width, made.canvas.height, pageW, pageH);
1927
+ var base64 = bytesToBase64(pdfBytes);
1928
+ if (api && api.postMessage) {
1929
+ api.postMessage({ type: 'exportFile', data: base64, ext: 'pdf', path: sourcePath, fileName: fileName, markdown: markdown });
1930
+ }
1931
+ } catch (e) {
1932
+ setStatus(t('status.exportPdfFailed', { err: String(e) }), true);
1933
+ }
1934
+ }
1935
+
1936
+ // Bound directly to real clicks in this document — the whole point of
1937
+ // moving these out of the host toolbar. `preventDefault` keeps the button
1938
+ // from stealing the selection/focus the clipboard path relies on.
1939
+ function bindAction(el, fn) {
1940
+ if (!el) return;
1941
+ el.addEventListener('click', function (ev) {
1942
+ ev.preventDefault();
1943
+ if (el.disabled) return;
1944
+ el.disabled = true;
1945
+ Promise.resolve()
1946
+ .then(fn)
1947
+ .catch(function (e) { setStatus(String(e), true); })
1948
+ .then(function () { el.disabled = false; });
1949
+ });
1950
+ }
1951
+
1952
+ bindAction(actCopyWx, copyHtml);
1953
+ bindAction(actCopyImg, copyImage);
1954
+ bindAction(actSaveImg, exportImage);
1955
+ bindAction(actSavePdf, exportPdf);
1956
+ bindAction(actAiStyle, askAiStyle);
1957
+
1958
+ // Collapse state persists across reopens (per browser profile, not per
1959
+ // document) via localStorage — a purely cosmetic preference, so falling
1960
+ // back to "expanded" on any storage error is fine.
1961
+ var ACTIONS_COLLAPSE_KEY = 'md-editor-actions-collapsed';
1962
+ (function initActionsCollapse() {
1963
+ var collapsed = false;
1964
+ try { collapsed = localStorage.getItem(ACTIONS_COLLAPSE_KEY) === '1'; } catch (e) { /* ignore */ }
1965
+ if (actions) actions.classList.toggle('collapsed', collapsed);
1966
+ })();
1967
+ if (actToggle) {
1968
+ actToggle.addEventListener('click', function (ev) {
1969
+ ev.preventDefault();
1970
+ var collapsed = actions.classList.toggle('collapsed');
1971
+ try { localStorage.setItem(ACTIONS_COLLAPSE_KEY, collapsed ? '1' : '0'); } catch (e) { /* ignore */ }
1972
+ });
1973
+ }
1974
+
1975
+ function saveNow() {
1976
+ writeToDisk(markdown);
1977
+ }
1978
+
1979
+ async function askAiStyle() {
1980
+ if (!markdown || !api || !api.composer) { setStatus(t('status.pleaseOpenArticle'), true); return; }
1981
+ var baseNote = style === 'custom' ? t('aiStyle.baseNoteCustom') : t('aiStyle.baseNoteOther', { style: style });
1982
+ try {
1983
+ await api.composer.addContexts([{
1984
+ type: 'annotation',
1985
+ label: t('aiStyle.label', { file: fileName || t('common.markdownDocDefault') }),
1986
+ note: t('aiStyle.note'),
1987
+ promptText: t('aiStyle.promptText', {
1988
+ baseNote: baseNote,
1989
+ path: sourcePath || t('aiStyle.pathMissing'),
1990
+ }),
1991
+ reminder: t('aiStyle.reminder'),
1992
+ }]);
1993
+ setStatus(t('aiStyle.requested'));
1994
+ } catch (e) {
1995
+ setStatus(String(e), true);
1996
+ }
1997
+ }
1998
+
1999
+ // ---- Toolbar (finch:menu) ----
2000
+
2001
+ function setStyle(next) {
2002
+ style = next;
2003
+ if (mode === 'preview') render();
2004
+ }
2005
+
2006
+ function setEditorFontSize(size) {
2007
+ if (size !== 14 && size !== 16 && size !== 18) return;
2008
+ editorFontSize = size;
2009
+ cm.setFontSize(size);
2010
+ try { localStorage.setItem('md-editor-font-size', String(size)); } catch (e) {}
2011
+ syncToolbar();
2012
+ }
2013
+
2014
+ function setEditorFont(font) {
2015
+ if (!EDITOR_FONTS[font]) return;
2016
+ editorFont = font;
2017
+ cm.setFontFamily(EDITOR_FONTS[font]);
2018
+ try { localStorage.setItem('md-editor-font-family', font); } catch (e) {}
2019
+ syncToolbar();
2020
+ }
2021
+
2022
+ function toggleAnnotations() {
2023
+ annotationsEnabled = !annotationsEnabled;
2024
+ if (!annotationsEnabled) closePopup();
2025
+ syncToolbar();
2026
+ setStatus(annotationsEnabled ? t('status.annotateOn') : t('status.annotateOff'));
2027
+ }
2028
+
2029
+ // Simple 3-way confirm ("保存并返回" / "不保存" / "取消") resolved as
2030
+ // 'save' | 'discard' | 'cancel'. Backdrop click and Escape both count as
2031
+ // cancel — staying put is the safe default when in doubt.
2032
+ var confirmResolve = null;
2033
+ function closeConfirm(result) {
2034
+ confirmOverlay.hidden = true;
2035
+ var resolve = confirmResolve;
2036
+ confirmResolve = null;
2037
+ if (resolve) resolve(result);
2038
+ }
2039
+ function askUnsavedConfirm(message) {
2040
+ return new Promise(function (resolve) {
2041
+ confirmResolve = resolve;
2042
+ confirmMessage.textContent = message;
2043
+ confirmOverlay.hidden = false;
2044
+ confirmSave.focus();
2045
+ });
2046
+ }
2047
+ if (confirmCancel) confirmCancel.addEventListener('click', function () { closeConfirm('cancel'); });
2048
+ if (confirmDiscard) confirmDiscard.addEventListener('click', function () { closeConfirm('discard'); });
2049
+ if (confirmSave) confirmSave.addEventListener('click', function () { closeConfirm('save'); });
2050
+ if (confirmOverlay) confirmOverlay.addEventListener('click', function (e) { if (e.target === confirmOverlay) closeConfirm('cancel'); });
2051
+ document.addEventListener('keydown', function (e) {
2052
+ if (e.key === 'Escape' && confirmResolve) closeConfirm('cancel');
2053
+ });
2054
+
2055
+ // Waits for a save round-trip to finish (unlike the fire-and-forget
2056
+ // saveNow()/Cmd+S path) so callers can safely navigate away afterward.
2057
+ async function saveNowAndWait() {
2058
+ return await writeToDisk(markdown);
2059
+ }
2060
+
2061
+ // Actually resets the in-page document state (not the file on disk) and
2062
+ // tells the backend to stop watching it, then shows the launch page again.
2063
+ function resetToHome() {
2064
+ stopWatching();
2065
+ closePopup();
2066
+ markdown = '';
2067
+ name = '';
2068
+ fileName = '';
2069
+ sourcePath = '';
2070
+ savedMarkdown = null;
2071
+ html = '';
2072
+ annotationsEnabled = false;
2073
+ mode = 'edit';
2074
+ if (cm) cm.setValue('');
2075
+ setPanelTitle(t('home.title'));
2076
+ showHtml('');
2077
+ if (api && api.postMessage) api.postMessage({ type: 'goHome' });
2078
+ updateEmptyState();
2079
+ showPane();
2080
+ syncToolbar();
2081
+ setStatus('');
2082
+ }
2083
+
2084
+ // Manual "back to home" navigation. If there are unsaved edits, ask first —
2085
+ // deliberately a plain in-page overlay (see .confirm-overlay CSS comment)
2086
+ // rather than window.finch.ui.confirm(), since the "home" toolbar button is
2087
+ // the only way to trigger this and it arrives here without a real user
2088
+ // gesture the Bridge dialog could rely on.
2089
+ async function goHome() {
2090
+ if (dirty) {
2091
+ var choice = await askUnsavedConfirm(t('confirm.message', { file: fileName || t('common.markdownDocDefault') }));
2092
+ if (choice === 'cancel') return;
2093
+ // The user has committed to leaving (save-then-leave or discard-then-
2094
+ // leave) — tell the backend to stop watching *before* the save write
2095
+ // below, not after. Otherwise our own writeFile() triggers its own
2096
+ // fs.watch tick, which can race resetToHome() and push the just-saved
2097
+ // document straight back onto a panel that already reset to Home.
2098
+ if (api && api.postMessage) api.postMessage({ type: 'goHome' });
2099
+ if (choice === 'save') {
2100
+ var saved = await saveNowAndWait();
2101
+ if (!saved) { setStatus(t('status.saveFailedNoReturn'), true); return; }
2102
+ }
2103
+ // choice === 'discard' falls through and leaves without saving.
2104
+ }
2105
+ resetToHome();
2106
+ }
2107
+
2108
+ function handleMenu(itemId) {
2109
+ if (api && api.postMessage) api.postMessage({ type: 'clientLog', message: 'finch:menu received, itemId=' + itemId });
2110
+ if (itemId === 'home') { goHome(); return; }
2111
+ if (itemId === 'open') { openFile(); return; }
2112
+ if (itemId === 'annotate') { toggleAnnotations(); return; }
2113
+ if (itemId === 'mode') { setMode(mode === 'edit' ? 'preview' : 'edit'); return; }
2114
+ if (itemId === 'save') { saveNow(); return; }
2115
+ if (itemId === 'reload') { render(); return; }
2116
+ if (itemId && itemId.indexOf('font-size:') === 0) { setEditorFontSize(Number(itemId.slice('font-size:'.length))); return; }
2117
+ if (itemId && itemId.indexOf('font-family:') === 0) { setEditorFont(itemId.slice('font-family:'.length)); return; }
2118
+ if (itemId === 'about') { setStatus(t('status.rendererAbout')); return; }
2119
+ // These two must be checked before the generic 'style:' fallback below,
2120
+ // since both also start with the literal prefix "style:".
2121
+ if (itemId && itemId.indexOf('style:slot-use:') === 0) { applyStyleSlot(+itemId.slice('style:slot-use:'.length)); return; }
2122
+ if (itemId && itemId.indexOf('style:slot-save:') === 0) { saveStyleSlot(+itemId.slice('style:slot-save:'.length)); return; }
2123
+ if (itemId && itemId.indexOf('style:') === 0) { setStyle(itemId.slice(6)); return; }
2124
+ }
2125
+
2126
+ function applyStyleSlot(index) {
2127
+ var slot = styleSlots[index];
2128
+ if (!slot) return;
2129
+ customCss = slot.css;
2130
+ customStyleLabel = slot.label;
2131
+ style = 'custom';
2132
+ setStatus(t('status.appliedStyleSlot', { label: slot.label }));
2133
+ if (mode === 'preview') render(); else setMode('preview');
2134
+ syncToolbar();
2135
+ }
2136
+
2137
+ function saveStyleSlot(index) {
2138
+ if (style !== 'custom' || !customCss) {
2139
+ setStatus(t('status.needCustomStyleFirst'), true);
2140
+ return;
2141
+ }
2142
+ var label = customStyleLabel || t('common.customStyleDefault');
2143
+ if (api && api.postMessage) {
2144
+ api.postMessage({ type: 'saveStyleSlot', slot: index, css: customCss, label: label });
2145
+ }
2146
+ }
2147
+
2148
+ // ---- Backend messages ----
2149
+
2150
+ if (api && api.onMessage) {
2151
+ api.onMessage(function (m) {
2152
+ if (!m || typeof m !== 'object') return;
2153
+ if (m.type === 'ready') {
2154
+ pickFileSupported = !!m.pickFileSupported;
2155
+ styleSlots = Array.isArray(m.styleSlots) ? m.styleSlots : [null, null, null];
2156
+ // Reconcile the navigator.language guess (used for the very first,
2157
+ // synchronous paint) against the host's real locale (ctx.i18n.locale,
2158
+ // forwarded from index.ts). Only re-render if it actually flips zh/en
2159
+ // — this arrives early enough that the recent-documents grid and
2160
+ // toolbar haven't rendered with the wrong language yet in practice.
2161
+ if (typeof m.locale === 'string' && m.locale) {
2162
+ var nextIsZh = /^zh/i.test(m.locale);
2163
+ if (nextIsZh !== isZh) { isZh = nextIsZh; applyStaticI18n(); updatePopupPlaceholder(); }
2164
+ }
2165
+ if (m.assistantName) { assistantName = m.assistantName; updatePopupPlaceholder(); }
2166
+ updateHomeTagline();
2167
+ if (m.homeDir) { homeDir = m.homeDir; renderHomeCwd(); }
2168
+ syncToolbar();
2169
+ return;
2170
+ }
2171
+ if (m.type === 'styleSlots') {
2172
+ styleSlots = Array.isArray(m.styleSlots) ? m.styleSlots : [null, null, null];
2173
+ if (typeof m.savedSlot === 'number' && styleSlots[m.savedSlot]) {
2174
+ setStatus(t('status.savedToSlot', { slot: m.savedSlot + 1, label: styleSlots[m.savedSlot].label }));
2175
+ }
2176
+ syncToolbar();
2177
+ return;
2178
+ }
2179
+ if (m.type === 'clipboardImages') {
2180
+ var pendingClipboard = pendingClipboardImages[m.requestId];
2181
+ if (pendingClipboard) {
2182
+ delete pendingClipboardImages[m.requestId];
2183
+ var clipboardHtml = pendingClipboard.sourceHtml;
2184
+ var imageUrls = m.images && typeof m.images === 'object' ? m.images : {};
2185
+ Object.keys(imageUrls).forEach(function (originalUrl) {
2186
+ if (typeof imageUrls[originalUrl] === 'string') clipboardHtml = clipboardHtml.split(originalUrl).join(imageUrls[originalUrl]);
2187
+ });
2188
+ pendingClipboard.resolve(compactClipboardImageLayout(clipboardHtml));
2189
+ }
2190
+ return;
2191
+ }
2192
+ if (m.type === 'clipboardImagesError') {
2193
+ var pendingClipboardErr = pendingClipboardImages[m.requestId];
2194
+ if (pendingClipboardErr) { delete pendingClipboardImages[m.requestId]; pendingClipboardErr.reject(new Error(m.message || t('status.clipboardPrepareFailedDefault'))); }
2195
+ return;
2196
+ }
2197
+ if (m.type === 'pastedImage') {
2198
+ var pendingPaste = pendingPasteImages[m.requestId];
2199
+ if (pendingPaste) { delete pendingPasteImages[m.requestId]; pendingPaste.resolve(m.url); }
2200
+ return;
2201
+ }
2202
+ if (m.type === 'pasteImageError') {
2203
+ var pendingPasteErr = pendingPasteImages[m.requestId];
2204
+ if (pendingPasteErr) { delete pendingPasteImages[m.requestId]; pendingPasteErr.reject(new Error(m.message || t('status.pasteImageFailedDefault'))); }
2205
+ setStatus(t('status.pasteImageFailed', { msg: m.message || '' }));
2206
+ return;
2207
+ }
2208
+ if (m.type === 'finch:env') {
2209
+ // Panel scope changed (first open, or Home → Session relocation): rescope
2210
+ // the recent list to this panel's own working directory.
2211
+ var incomingCwd = m.cwd || '';
2212
+ if (incomingCwd !== envCwd) {
2213
+ envCwd = incomingCwd;
2214
+ renderHomeCwd();
2215
+ renderRecentDocuments([]);
2216
+ }
2217
+ if (!empty.hidden) requestRecentDocuments();
2218
+ return;
2219
+ }
2220
+ if (m.type === 'recentDocuments') {
2221
+ // Ignore a late reply for a directory we no longer belong to.
2222
+ if ((m.cwd || '') !== envCwd) return;
2223
+ renderRecentDocuments(m.documents);
2224
+ return;
2225
+ }
2226
+ if (m.type === 'status') { setStatus(m.message); return; }
2227
+ if (m.type === 'lastFileUnavailable') { return; }
2228
+ if (m.type === 'finch:menu') { handleMenu(m.itemId); return; }
2229
+ if (m.type === 'document') {
2230
+ var openedFromPicker = nativePickPending;
2231
+ nativePickPending = false;
2232
+ clearNativePickTimer();
2233
+ var incoming = m.markdown || '';
2234
+ var incomingPath = m.path || '';
2235
+ // A different absolute path means this is a genuine "switch to
2236
+ // another document" push (e.g. the AI just created/opened a second
2237
+ // file while this panel already had one loaded) — not an echo of our
2238
+ // own save, and not an external edit of the file we're currently
2239
+ // looking at, so neither of the two guards below should apply to it.
2240
+ var isDifferentDocument = !!incomingPath && !!sourcePath && incomingPath !== sourcePath;
2241
+ if (!isDifferentDocument && incoming === savedMarkdown) { syncToolbar(); return; } // echo of our own save
2242
+ var isFirst = savedMarkdown === null;
2243
+ var wasLoadingFromHome = loadingFromHome;
2244
+ loadingFromHome = false;
2245
+ var applied = applyDocument(incoming, m.title || t('common.markdownDocDefault'), m.path || sourcePath, openedFromPicker || isFirst || isDifferentDocument);
2246
+ if (!applied) syncToolbar(); // still clear the "open" button's pending/disabled state
2247
+ if (applied && wasLoadingFromHome) setStatus(t('status.opened', { file: fileName }));
2248
+ else if (applied && !isFirst) setStatus(t('status.contentUpdated'));
2249
+ return;
2250
+ }
2251
+ if (m.type === 'bmRendered') {
2252
+ if (m.requestId !== renderId) return;
2253
+ showHtml(m.html || '');
2254
+ updateEmptyState();
2255
+ syncToolbar(); // `html` just became available (or changed) — refresh the copy menu's disabled state
2256
+ setStatus('');
2257
+ return;
2258
+ }
2259
+ if (m.type === 'savedMarkdown') {
2260
+ var savedContent = pendingSaveContents[m.requestId];
2261
+ delete pendingSaveContents[m.requestId];
2262
+ var saveResolve = pendingSaveResolvers[m.requestId];
2263
+ delete pendingSaveResolvers[m.requestId];
2264
+ if (m.requestId !== saveId || typeof savedContent !== 'string') {
2265
+ if (saveResolve) saveResolve(false);
2266
+ return;
2267
+ }
2268
+ savedMarkdown = savedContent;
2269
+ setDirty(markdown !== savedContent, markdown === savedContent);
2270
+ setStatus(markdown === savedContent ? t('status.saved') : t('status.savedWithNewChanges'));
2271
+ if (saveResolve) saveResolve(true);
2272
+ return;
2273
+ }
2274
+ if (m.type === 'customStyleSet') {
2275
+ customCss = m.css || '';
2276
+ customStyleLabel = m.label || t('common.customStyleDefault');
2277
+ style = 'custom';
2278
+ styleSlots = Array.isArray(m.styleSlots) ? m.styleSlots : styleSlots;
2279
+ setStatus(t('status.savedAppliedToSlot', {
2280
+ labelPrefix: m.label ? m.label + ' · ' : '',
2281
+ slot: typeof m.savedSlot === 'number' ? m.savedSlot + 1 : '',
2282
+ }));
2283
+ if (mode === 'preview') render(); else setMode('preview');
2284
+ syncToolbar();
2285
+ return;
2286
+ }
2287
+ if (m.type === 'exported') { setStatus(t('status.exported', { path: m.path })); return; }
2288
+ if (m.type === 'watchStarted') { return; }
2289
+ if (m.type === 'pickCancelled') {
2290
+ // User closed the native picker without choosing a file — clear the
2291
+ // pending guard so the next click opens a fresh picker normally
2292
+ // instead of assuming this request is still hanging.
2293
+ nativePickPending = false;
2294
+ clearNativePickTimer();
2295
+ syncToolbar();
2296
+ setStatus('');
2297
+ return;
2298
+ }
2299
+ if (m.type === 'error') {
2300
+ loadingFromHome = false; // don't let a stale flag mislabel the next unrelated document push
2301
+ // A save failure surfaces here (no matching 'savedMarkdown' will ever
2302
+ // arrive for that requestId) — resolve any awaiter as failed so the
2303
+ // unsaved-changes confirm doesn't hang forever waiting on it.
2304
+ Object.keys(pendingSaveResolvers).forEach(function (id) {
2305
+ var resolve = pendingSaveResolvers[id];
2306
+ delete pendingSaveResolvers[id];
2307
+ if (resolve) resolve(false);
2308
+ });
2309
+ // A `fallback` error means the native picker timed out or errored on the
2310
+ // backend. We cannot silently re-trigger the OS file dialog here — Chromium
2311
+ // only allows showOpenFilePicker()/<input>.click() inside a real, still-fresh
2312
+ // user gesture, and this message arrives asynchronously well after the click
2313
+ // that started it. So just stop trusting the native path for the rest of this
2314
+ // session; the next "Open article" click (a fresh gesture) uses the reliable
2315
+ // in-page picker directly instead of hanging again.
2316
+ nativePickPending = false;
2317
+ clearNativePickTimer();
2318
+ if (m.fallback) pickFileSupported = false;
2319
+ syncToolbar();
2320
+ setStatus(m.message, true);
2321
+ return;
2322
+ }
2323
+ });
2324
+ }
2325
+
2326
+ // The host's own initial `ready` push (sent right when the panel is created,
2327
+ // from `onDidOpenPanel`) races against this guest page loading and attaching
2328
+ // the `api.onMessage` listener above — it can be sent before we're listening
2329
+ // and silently lost, permanently leaving `pickFileSupported` at its default
2330
+ // `false`. So instead of only relying on that push, we ask for it ourselves
2331
+ // once we're actually ready to receive the answer; by construction that
2332
+ // request can't be sent before our own listener exists.
2333
+ if (api && api.postMessage) api.postMessage({ type: 'panelReady' });
2334
+
2335
+ // Remember-last-file: if nothing has loaded shortly after opening (no tool
2336
+ // call, no restored payload), ask the backend for the last successfully
2337
+ // opened path so the user doesn't have to reopen it by hand every time.
2338
+ setTimeout(function () {
2339
+ if (!hasDocument() && api && api.postMessage) api.postMessage({ type: 'requestLastFile' });
2340
+ // `finch:env` normally lands before this; the retry covers a page rebind
2341
+ // that missed the push, so Home still fills in its recent documents.
2342
+ if (!recentRequested) requestRecentDocuments();
2343
+ }, 400);
2344
+
2345
+ applyStaticI18n();
2346
+ updateHomeTagline();
2347
+ updatePopupPlaceholder();
2348
+ updateEmptyState();
2349
+ showPane();
2350
+ })();
2351
+ </script>
2352
+ </body>
2353
+ </html>