finch-markdown-editor 0.1.9 → 0.2.1

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