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