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