dsh-fishpai 1.0.2

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/lib/client.js ADDED
@@ -0,0 +1,2770 @@
1
+ /**
2
+ * 自动生成,请勿手改 —— 源码在 client/,用 `npm run build` 重新生成。
3
+ * 改完记得把本文件一起提交:`dsh plugin add github:` 只安装、不构建。
4
+ */
5
+ window.__ModuleLoader__.load({
6
+ id: "dsh-fishpai",
7
+ factory: (require) => {
8
+ var module = { exports: {} };
9
+ var exports = module.exports;
10
+ var __create = Object.create;
11
+ var __defProp = Object.defineProperty;
12
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
13
+ var __getOwnPropNames = Object.getOwnPropertyNames;
14
+ var __getProtoOf = Object.getPrototypeOf;
15
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
16
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
17
+ var __export = (target, all) => {
18
+ for (var name2 in all)
19
+ __defProp(target, name2, { get: all[name2], enumerable: true });
20
+ };
21
+ var __copyProps = (to, from, except, desc) => {
22
+ if (from && typeof from === "object" || typeof from === "function") {
23
+ for (let key of __getOwnPropNames(from))
24
+ if (!__hasOwnProp.call(to, key) && key !== except)
25
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
26
+ }
27
+ return to;
28
+ };
29
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
30
+ // If the importer is in node compatibility mode or this is not an ESM
31
+ // file that has been converted to a CommonJS file using a Babel-
32
+ // compatible transform (i.e. "__esModule" has not been set), then set
33
+ // "default" to the CommonJS "module.exports" for node compatibility.
34
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
35
+ mod
36
+ ));
37
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
38
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
39
+
40
+ // client/index.tsx
41
+ var index_exports = {};
42
+ __export(index_exports, {
43
+ apply: () => apply,
44
+ inject: () => inject,
45
+ name: () => name
46
+ });
47
+ module.exports = __toCommonJS(index_exports);
48
+ var React2 = __toESM(require("react"), 1);
49
+
50
+ // client/panel.tsx
51
+ var React = __toESM(require("react"), 1);
52
+ var import_react = require("react");
53
+
54
+ // client/api.ts
55
+ var ConflictError = class extends Error {
56
+ constructor(revision, markdown) {
57
+ super(`revision 冲突:服务端是 ${revision}`);
58
+ __publicField(this, "revision", revision);
59
+ __publicField(this, "markdown", markdown);
60
+ }
61
+ };
62
+ var PREFIX = "/fishpai/api";
63
+ async function call(path, init) {
64
+ const res = await fetch(`${PREFIX}${path}`, {
65
+ ...init,
66
+ headers: init?.body ? { "content-type": "application/json", ...init?.headers || {} } : init?.headers
67
+ });
68
+ let payload = null;
69
+ try {
70
+ payload = await res.json();
71
+ } catch {
72
+ payload = null;
73
+ }
74
+ if (res.status === 409 && payload && payload.conflict) {
75
+ throw new ConflictError(Number(payload.revision) || 0, String(payload.markdown ?? ""));
76
+ }
77
+ if (!res.ok || !payload || payload.ok === false) {
78
+ throw new Error(payload && payload.error || `请求失败(HTTP ${res.status})`);
79
+ }
80
+ return payload;
81
+ }
82
+ var q = (params) => Object.entries(params).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
83
+ var api = {
84
+ state: (sessionId) => call(`/state?${q({ sessionId })}`),
85
+ themes: (sessionId) => call(`/themes?${q({ sessionId })}`),
86
+ doc: (sessionId, docKey) => call(`/doc?${q({ sessionId, docKey })}`),
87
+ save: (sessionId, docKey, markdown, baseRevision, meta) => call(`/doc`, {
88
+ method: "PUT",
89
+ body: JSON.stringify({ sessionId, docKey, markdown, baseRevision, meta })
90
+ }),
91
+ /** 面板上自己起一篇空白文档(不必等模型 fishpai_open)。 */
92
+ createDoc: (sessionId, title) => call(`/doc`, {
93
+ method: "POST",
94
+ body: JSON.stringify({ sessionId, title })
95
+ }),
96
+ /** 面板主题列表里的 ×:删掉工作目录级的那一套「自定义主题」。 */
97
+ clearTheme: (sessionId) => call("/theme", {
98
+ method: "POST",
99
+ body: JSON.stringify({ sessionId, action: "clear" })
100
+ }),
101
+ activate: (sessionId, docKey) => call(`/active`, {
102
+ method: "POST",
103
+ body: JSON.stringify({ sessionId, docKey })
104
+ }),
105
+ meta: (sessionId, docKey, meta) => call(`/meta`, { method: "POST", body: JSON.stringify({ sessionId, docKey, meta }) }),
106
+ /**
107
+ * 面板上粘贴/拖进来的图片:宿主存到**文档同级的 `assets/`**,回一个相对文档目录的 `src`,
108
+ * 客户端把它写成 `![](assets/xxx.png)` 插进正文(正文的修改仍然只走 `/doc`)。
109
+ */
110
+ upload: (sessionId, docKey, payload) => call(`/upload`, {
111
+ method: "POST",
112
+ body: JSON.stringify({ sessionId, docKey, ...payload })
113
+ }),
114
+ /**
115
+ * 预览渲染。宿主回带的 `blocks` / `notes` / `placeholders` / `images` **是跟着传进去的
116
+ * markdown 走的**(不是磁盘上那份),面板据此在打字时就刷新块清单、批注锚点与图片提示。
117
+ *
118
+ * `linkCount` / `hasCode` 同样来自**渲染结果**:面板靠它们决定「脚注」「Mac 代码框」
119
+ * 两个开关该不该置灰,而不是自己拿正则猜正文。
120
+ *
121
+ * `hljsCss` 是 highlight.js 配色:预览 iframe 是沙箱 srcdoc,里面没有它的样式表,
122
+ * 代码块 token 只有 class、没有颜色——宿主把同一份色表(hljs-map.json)生成 CSS 带下来,
123
+ * 客户端注入 srcdoc。发布产物不吃它(走内联),所以 golden 不受影响。
124
+ */
125
+ renderPreview: (sessionId, docKey, markdown, meta) => call(`/render`, {
126
+ method: "POST",
127
+ body: JSON.stringify({ sessionId, docKey, markdown, meta, mode: "preview" })
128
+ }),
129
+ renderPublish: (sessionId, docKey, markdown, meta) => call(`/render`, {
130
+ method: "POST",
131
+ body: JSON.stringify({ sessionId, docKey, markdown, meta, mode: "publish" })
132
+ }),
133
+ notes: (sessionId, docKey, payload) => call(`/notes`, {
134
+ method: "POST",
135
+ body: JSON.stringify({ sessionId, docKey, ...payload })
136
+ }),
137
+ history: (sessionId, docKey, payload) => call(`/history`, {
138
+ method: "POST",
139
+ body: JSON.stringify({ sessionId, docKey, ...payload })
140
+ }),
141
+ tabOpened: (sessionId, docKey) => call(`/tab-opened`, { method: "POST", body: JSON.stringify({ sessionId, docKey }) }),
142
+ assetUrl: (sessionId, docKey, src) => `${PREFIX}/asset?${q({ sessionId, docKey, src })}`
143
+ };
144
+
145
+ // client/store.ts
146
+ var MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
147
+ var DEFAULT_META = {
148
+ theme: "default",
149
+ color: null,
150
+ font: "sans",
151
+ fontSize: "16px",
152
+ footnotes: true,
153
+ macCodeBlock: true,
154
+ mobile: false
155
+ };
156
+ function initialState(sessionId) {
157
+ return {
158
+ status: "loading",
159
+ error: null,
160
+ sessionId,
161
+ docKey: null,
162
+ path: "",
163
+ title: "",
164
+ markdown: "",
165
+ savedMarkdown: "",
166
+ revision: 0,
167
+ meta: { ...DEFAULT_META },
168
+ blocks: [],
169
+ notes: [],
170
+ placeholders: [],
171
+ images: [],
172
+ linkCount: 0,
173
+ hasCode: false,
174
+ imageMap: {},
175
+ history: [],
176
+ docs: [],
177
+ themes: [],
178
+ presets: [],
179
+ fonts: ["sans", "serif", "mono"],
180
+ sizes: ["14px", "15px", "16px", "17px", "18px"],
181
+ previewHtml: "",
182
+ hljsCss: "",
183
+ themeName: "",
184
+ previewing: false,
185
+ saving: false,
186
+ dirty: false,
187
+ conflict: null,
188
+ external: null,
189
+ caretLine: 1,
190
+ toast: null
191
+ };
192
+ }
193
+ var ENTITIES = { "&amp;": "&", "&lt;": "<", "&gt;": ">", "&quot;": '"', "&#39;": "'" };
194
+ function decodeAttr(value) {
195
+ return value.replace(/&(?:amp|lt|gt|quot|#39);/g, (m) => ENTITIES[m] || m);
196
+ }
197
+ function inlineImages(html, imageMap) {
198
+ if (!html || !Object.keys(imageMap).length) return html;
199
+ return html.replace(/(<img\b[^>]*?\bsrc=")([^"]*)(")/g, (full, pre, src, post) => {
200
+ const hit = imageMap[src] || imageMap[decodeAttr(src)];
201
+ return hit ? `${pre}${hit}${post}` : full;
202
+ });
203
+ }
204
+ function buildSrcdoc(html, imageMap = {}, hljsCss = "") {
205
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
206
+ <meta name="viewport" content="width=device-width,initial-scale=1">
207
+ <style>
208
+ html,body{margin:0;padding:0;background:#fff}
209
+ fp-block{display:block;height:0;overflow:hidden}
210
+ img{max-width:100%;height:auto}
211
+ ${hljsCss}
212
+ </style></head><body>${inlineImages(html, imageMap)}
213
+ <script>
214
+ (function(){
215
+ var top=null;
216
+ function report(){
217
+ var marks=document.querySelectorAll('fp-block');
218
+ var best=null;
219
+ for(var i=0;i<marks.length;i++){
220
+ var r=marks[i].getBoundingClientRect();
221
+ if(r.top<=8){best=marks[i].getAttribute('data-b')}
222
+ else if(best===null){best=marks[i].getAttribute('data-b');break}
223
+ }
224
+ if(report.last!==best){report.last=best;parent.postMessage({fishpai:'visible',id:best},'*')}
225
+ }
226
+ addEventListener('scroll',report,{passive:true});
227
+ addEventListener('message',function(e){
228
+ var d=e.data;
229
+ if(!d||d.fishpai!=='reveal')return;
230
+ var el=document.querySelector('fp-block[data-b="'+d.id+'"]');
231
+ if(el&&el.nextElementSibling)el.nextElementSibling.scrollIntoView({block:'start'});
232
+ else if(el)el.scrollIntoView({block:'start'});
233
+ });
234
+ report();
235
+ })();
236
+ <\/script></body></html>`;
237
+ }
238
+ function blobToDataUrl(blob) {
239
+ return new Promise((resolve, reject) => {
240
+ const Reader = globalThis.FileReader;
241
+ if (!Reader) return reject(new Error("这个浏览器不支持本地图片预览"));
242
+ const reader = new Reader();
243
+ reader.onload = () => resolve(String(reader.result || ""));
244
+ reader.onerror = () => reject(new Error("图片读取失败"));
245
+ reader.readAsDataURL(blob);
246
+ });
247
+ }
248
+ async function fileToBase64(file) {
249
+ const bytes = new Uint8Array(await file.arrayBuffer());
250
+ let binary = "";
251
+ const CHUNK = 32768;
252
+ for (let i = 0; i < bytes.length; i += CHUNK) {
253
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)));
254
+ }
255
+ return btoa(binary);
256
+ }
257
+ function createFishpaiStore(sessionId, onDocLoaded) {
258
+ let state = initialState(sessionId);
259
+ const listeners = /* @__PURE__ */ new Set();
260
+ let toastSeq = 0;
261
+ let saveTimer = null;
262
+ let previewTimer = null;
263
+ const toastTimers = /* @__PURE__ */ new Set();
264
+ const emit = () => {
265
+ for (const fn of listeners) fn();
266
+ };
267
+ const patch = (next) => {
268
+ state = { ...state, ...next };
269
+ emit();
270
+ };
271
+ const toast = (text, kind = "info") => {
272
+ toastSeq += 1;
273
+ const id = toastSeq;
274
+ patch({ toast: { id, text, kind } });
275
+ const timer = setTimeout(() => {
276
+ toastTimers.delete(timer);
277
+ if (state.toast && state.toast.id === id) patch({ toast: null });
278
+ }, kind === "error" ? 5e3 : 2600);
279
+ toastTimers.add(timer);
280
+ };
281
+ const imageCache = /* @__PURE__ */ new Map();
282
+ async function ensureImages(images, docKey) {
283
+ const bucket = imageCache.get(docKey) || /* @__PURE__ */ new Map();
284
+ imageCache.set(docKey, bucket);
285
+ const wanted = images.filter((img) => img.embed && !bucket.has(img.src));
286
+ if (!wanted.length) return;
287
+ await Promise.all(
288
+ wanted.map(async (img) => {
289
+ try {
290
+ const res = await fetch(api.assetUrl(state.sessionId, docKey, img.src));
291
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
292
+ bucket.set(img.src, await blobToDataUrl(await res.blob()));
293
+ } catch {
294
+ bucket.set(img.src, "");
295
+ }
296
+ })
297
+ );
298
+ if (state.docKey !== docKey) return;
299
+ patch({ imageMap: Object.fromEntries(bucket) });
300
+ }
301
+ let previewToken = 0;
302
+ let loadToken = 0;
303
+ let notesEpoch = 0;
304
+ async function refreshPreview(markdown, meta) {
305
+ if (!state.docKey) return;
306
+ const docKey = state.docKey;
307
+ const token = ++previewToken;
308
+ const epoch = notesEpoch;
309
+ patch({ previewing: true });
310
+ try {
311
+ const res = await api.renderPreview(state.sessionId, state.docKey, markdown, meta);
312
+ if (token !== previewToken) return;
313
+ patch({
314
+ previewHtml: res.html,
315
+ hljsCss: res.hljsCss || "",
316
+ blocks: res.blocks,
317
+ placeholders: res.placeholders,
318
+ images: res.images,
319
+ linkCount: res.linkCount,
320
+ hasCode: res.hasCode,
321
+ ...epoch === notesEpoch ? { notes: res.notes } : {},
322
+ themeName: res.themeName,
323
+ previewing: false,
324
+ // 成功即清掉上一次的失败红条——否则一次瞬时失败会一直挂到点「重试」或重载文档为止
325
+ error: null
326
+ });
327
+ void ensureImages(res.images, docKey);
328
+ } catch (error) {
329
+ if (token !== previewToken) return;
330
+ patch({ previewing: false, error: `预览渲染失败:${error.message}` });
331
+ }
332
+ }
333
+ const schedulePreview = () => {
334
+ if (previewTimer) clearTimeout(previewTimer);
335
+ previewTimer = setTimeout(() => refreshPreview(state.markdown, state.meta), 300);
336
+ };
337
+ let saveInFlight = false;
338
+ async function saveNow() {
339
+ if (!state.docKey || !state.dirty) return;
340
+ if (state.conflict) return;
341
+ if (state.external) return;
342
+ if (saveInFlight) return;
343
+ const snapshotMarkdown = state.markdown;
344
+ saveInFlight = true;
345
+ patch({ saving: true });
346
+ try {
347
+ const res = await api.save(state.sessionId, state.docKey, snapshotMarkdown, state.revision, state.meta);
348
+ patch({
349
+ saving: false,
350
+ revision: res.revision,
351
+ savedMarkdown: snapshotMarkdown,
352
+ dirty: state.markdown !== snapshotMarkdown,
353
+ conflict: null,
354
+ external: null,
355
+ // 每次写入都会留一份快照,宿主顺手把它回带过来——「历史」抽屉不用再手动刷新
356
+ ...res.history ? { history: res.history } : {}
357
+ });
358
+ } catch (error) {
359
+ if (error instanceof ConflictError) {
360
+ patch({ saving: false, conflict: { revision: error.revision, markdown: error.markdown } });
361
+ return;
362
+ }
363
+ patch({ saving: false, error: `保存失败:${error.message}` });
364
+ } finally {
365
+ saveInFlight = false;
366
+ }
367
+ if (state.dirty && !state.conflict) scheduleSave();
368
+ }
369
+ const scheduleSave = () => {
370
+ if (saveTimer) clearTimeout(saveTimer);
371
+ saveTimer = setTimeout(() => void saveNow(), 800);
372
+ };
373
+ async function loadDoc(docKey) {
374
+ if (!docKey) return;
375
+ const token = ++loadToken;
376
+ const dirtyBefore = state.dirty;
377
+ const res = await api.doc(state.sessionId, docKey);
378
+ if (token !== loadToken) return;
379
+ const keepTyped = state.dirty && !dirtyBefore;
380
+ notesEpoch += 1;
381
+ patch({
382
+ status: "ready",
383
+ error: null,
384
+ docKey,
385
+ path: res.doc.path,
386
+ title: res.doc.title,
387
+ ...keepTyped ? {} : { markdown: res.doc.markdown, savedMarkdown: res.doc.markdown, dirty: false },
388
+ revision: res.doc.revision,
389
+ meta: res.meta,
390
+ blocks: res.blocks,
391
+ notes: res.notes,
392
+ placeholders: res.placeholders,
393
+ images: res.images,
394
+ // `/doc` 不带渲染信息:置 `null` 表示"还不知道",开关因此**不会闪一下变灰**。
395
+ // 紧随其后的那次预览会给出真值(`false`/`0` 才是"确实没有")。
396
+ linkCount: null,
397
+ hasCode: null,
398
+ imageMap: Object.fromEntries(imageCache.get(docKey) || []),
399
+ history: res.history,
400
+ conflict: null,
401
+ external: null
402
+ });
403
+ onDocLoaded?.(docKey);
404
+ void ensureImages(res.images, docKey);
405
+ void refreshPreview(state.markdown, res.meta);
406
+ }
407
+ async function saveBeforeSwitch() {
408
+ if (!state.dirty || state.conflict || state.external) return true;
409
+ await saveNow();
410
+ return !state.dirty;
411
+ }
412
+ async function refreshThemes() {
413
+ const res = await api.themes(state.sessionId).catch(() => null);
414
+ if (res) patch({ themes: res.themes, presets: res.presets, sizes: res.sizes, fonts: res.fonts });
415
+ }
416
+ async function init() {
417
+ try {
418
+ patch({ status: "loading", error: null });
419
+ await refreshThemes();
420
+ const st = await api.state(state.sessionId);
421
+ patch({ docs: Array.isArray(st.docs) ? st.docs : [] });
422
+ if (!st.active) {
423
+ patch({ status: "empty" });
424
+ return;
425
+ }
426
+ await loadDoc(st.active.key);
427
+ } catch (error) {
428
+ patch({ status: "error", error: error.message });
429
+ }
430
+ }
431
+ const actions = {
432
+ init,
433
+ /**
434
+ * 面板上自己起一篇(空文档 + 一行标题),不必等模型 `fishpai_open`。
435
+ * 宿主那边会把它落到 `.fishpai/docs/` 下,和模型建的是同一类普通 .md。
436
+ */
437
+ async createDoc() {
438
+ try {
439
+ if (!await saveBeforeSwitch()) {
440
+ toast("保存没成功,已取消新建文档以免丢字", "error");
441
+ return;
442
+ }
443
+ const res = await api.createDoc(state.sessionId);
444
+ await loadDoc(res.docKey);
445
+ toast("新建了一篇空白文档,可以直接在编辑器里写");
446
+ } catch (error) {
447
+ toast(`新建文档失败:${error.message}`, "error");
448
+ }
449
+ },
450
+ /** 从「最近打开」切一篇:只改这个会话的当前文档,不动模型留下的打开请求。 */
451
+ async openDocByKey(key) {
452
+ if (!key || key === state.docKey) return;
453
+ try {
454
+ if (!await saveBeforeSwitch()) {
455
+ toast("保存没成功,已取消切换以免丢字", "error");
456
+ return;
457
+ }
458
+ await api.activate(state.sessionId, key);
459
+ await loadDoc(key);
460
+ } catch (error) {
461
+ toast(`打开失败:${error.message}`, "error");
462
+ }
463
+ },
464
+ setMarkdown(text) {
465
+ patch({ markdown: text, dirty: text !== state.savedMarkdown });
466
+ schedulePreview();
467
+ scheduleSave();
468
+ },
469
+ setCaret(line) {
470
+ if (line !== state.caretLine) patch({ caretLine: line });
471
+ },
472
+ /**
473
+ * 把一张粘贴/拖进来的图片存到宿主(文档同级的 `assets/`),回它的相对路径。
474
+ *
475
+ * 这里**只存**、不插正文:插哪里由编辑器按当下的光标决定(上传期间人可能挪了光标),
476
+ * 而且这样"存失败"与"改正文"是两件事——图没存进来,正文一个字都不会被碰。
477
+ */
478
+ async uploadImage(file) {
479
+ if (!state.docKey) throw new Error("还没有打开文档:先让模型 fishpai_open 一篇,或新建一篇");
480
+ if (file.size > MAX_UPLOAD_BYTES) {
481
+ throw new Error(`${(file.size / 1024 / 1024).toFixed(1)}MB 超过 ${MAX_UPLOAD_BYTES / 1024 / 1024}MB 上限,先压缩一下再粘`);
482
+ }
483
+ const data = await fileToBase64(file);
484
+ return api.upload(state.sessionId, state.docKey, { name: file.name, mime: file.type, data });
485
+ },
486
+ /** 光标所在块(加批注、跳预览都用它)。 */
487
+ currentBlock() {
488
+ const line = state.caretLine;
489
+ return state.blocks.find((b) => b.startLine <= line && line <= b.endLine) || state.blocks[0] || null;
490
+ },
491
+ async setMeta(next) {
492
+ const meta = { ...state.meta, ...next };
493
+ patch({ meta });
494
+ if (state.docKey) {
495
+ try {
496
+ await api.meta(state.sessionId, state.docKey, next);
497
+ } catch (error) {
498
+ toast(`设置未保存:${error.message}`, "error");
499
+ }
500
+ }
501
+ void refreshPreview(state.markdown, meta);
502
+ },
503
+ /**
504
+ * 删掉工作目录级的那一套「自定义主题」(面板主题列表「我的」那一组右边的 ×)。
505
+ * 只有一套,删了那一组就整个消失。宿主已把正用着它的当前文档退回「默认公众号」,
506
+ * 这里把本地的 meta 与预览也同步过去,免得预览还停在已经不存在的主题上。
507
+ */
508
+ async clearCustomTheme() {
509
+ try {
510
+ const res = await api.clearTheme(state.sessionId);
511
+ if (!res.removed) {
512
+ toast("本来就没有自定义主题,什么都没动");
513
+ return;
514
+ }
515
+ await refreshThemes();
516
+ if (state.meta.theme === "custom") {
517
+ const meta = { ...state.meta, theme: "default" };
518
+ patch({ meta });
519
+ void refreshPreview(state.markdown, meta);
520
+ }
521
+ toast(res.reverted ? "已移除自定义主题;当前文档退回「默认公众号」" : "已移除自定义主题");
522
+ } catch (error) {
523
+ toast(`移除失败:${error.message}`, "error");
524
+ }
525
+ },
526
+ /**
527
+ * 加一条批注。**返回是否真的加上了**——调用方据此决定要不要清空输入框:
528
+ * 没加上还把用户刚写的字抹掉,是这类面板最容易咬人的地方。
529
+ *
530
+ * 先落盘再取块:批注靠块 id / 块序号锚定,而宿主是按**磁盘上的正文**解析锚点的。
531
+ * 所以这里等保存完、再用同一次预览的块边界取块,锚点才和宿主看到的是同一份。
532
+ */
533
+ async addNote(text) {
534
+ if (!state.docKey) {
535
+ toast("还没有打开文档:先让模型 fishpai_open 一篇,再留批注", "error");
536
+ return false;
537
+ }
538
+ await saveNow();
539
+ await refreshPreview(state.markdown, state.meta);
540
+ const block = actions.currentBlock();
541
+ if (!block) {
542
+ toast("正文还是空的:先在编辑器里写一段,再把批注挂上去", "error");
543
+ return false;
544
+ }
545
+ try {
546
+ const res = await api.notes(state.sessionId, state.docKey, {
547
+ action: "add",
548
+ note: { blockId: block.id, blockIndex: block.index, text, author: "human" }
549
+ });
550
+ notesEpoch += 1;
551
+ patch({ notes: res.notes });
552
+ toast("批注已加;模型下次 fishpai_read 就能看到");
553
+ return true;
554
+ } catch (error) {
555
+ toast(`批注失败:${error.message} 文字还在输入框里,可以直接重试`, "error");
556
+ return false;
557
+ }
558
+ },
559
+ async updateNote(id, next) {
560
+ if (!state.docKey) return;
561
+ try {
562
+ const res = await api.notes(state.sessionId, state.docKey, { action: "update", id, patch: next });
563
+ notesEpoch += 1;
564
+ patch({ notes: res.notes });
565
+ } catch (error) {
566
+ toast(`批注更新失败:${error.message}`, "error");
567
+ }
568
+ },
569
+ async removeNote(id) {
570
+ if (!state.docKey) return;
571
+ try {
572
+ const res = await api.notes(state.sessionId, state.docKey, { action: "remove", id });
573
+ notesEpoch += 1;
574
+ patch({ notes: res.notes });
575
+ } catch (error) {
576
+ toast(`删除失败:${error.message}`, "error");
577
+ }
578
+ },
579
+ async restore(id, label) {
580
+ if (!state.docKey) return;
581
+ if (state.conflict) {
582
+ toast("先处理上方的冲突再回滚", "error");
583
+ return;
584
+ }
585
+ if (state.dirty) {
586
+ await saveNow();
587
+ if (state.dirty) {
588
+ toast("保存没成功,已取消回滚以免丢字", "error");
589
+ return;
590
+ }
591
+ }
592
+ try {
593
+ await api.history(state.sessionId, state.docKey, { action: "restore", id });
594
+ await loadDoc(state.docKey);
595
+ toast(`已回滚到 ${label}`);
596
+ } catch (error) {
597
+ if (error instanceof ConflictError) {
598
+ patch({ conflict: { revision: error.revision, markdown: error.markdown } });
599
+ return;
600
+ }
601
+ toast(`回滚失败:${error.message}`, "error");
602
+ }
603
+ },
604
+ /**
605
+ * 冲突处理。
606
+ * - `mine` :用我的覆盖(服务端那份留在历史里)
607
+ * - `theirs` :采用 AI 的版本——**先把我的草稿存进历史**,绝不静默丢字
608
+ */
609
+ async resolveConflict(choice) {
610
+ if (!state.conflict || !state.docKey) return;
611
+ const conflict = state.conflict;
612
+ const draft = state.markdown;
613
+ if (choice === "theirs") {
614
+ try {
615
+ await api.history(state.sessionId, state.docKey, { action: "stash", markdown: draft, label: "冲突时我的版本" });
616
+ } catch (error) {
617
+ toast(`先把你的稿子存进历史失败:${error.message}`, "error");
618
+ return;
619
+ }
620
+ patch({ markdown: conflict.markdown, savedMarkdown: conflict.markdown, revision: conflict.revision, conflict: null, dirty: false });
621
+ schedulePreview();
622
+ await actions.reload();
623
+ toast("已采用 AI 的版本;你的稿子已存进「历史」,点那一版可回滚取回");
624
+ return;
625
+ }
626
+ patch({ revision: conflict.revision, conflict: null });
627
+ await saveNow();
628
+ toast("已用你的版本覆盖,AI 的版本留在历史里");
629
+ },
630
+ /** 导出/复制用:拿「复制到公众号」形态的 HTML(本地图片已内嵌 base64)。 */
631
+ async publishHtml() {
632
+ if (!state.docKey) throw new Error("还没有打开文档");
633
+ await saveNow();
634
+ const res = await api.renderPublish(state.sessionId, state.docKey, state.markdown, state.meta);
635
+ return res.html;
636
+ },
637
+ /** 轮询发现宿主的当前文档变了(AI 写入 / 换了一篇 / 别的窗口写的 / 模型换了主题)。 */
638
+ async onActiveDoc(key, revision, themeKey) {
639
+ if (!key) return;
640
+ if (key === state.docKey && themeKey && themeKey !== state.meta.theme) {
641
+ patch({ meta: { ...state.meta, theme: themeKey } });
642
+ void refreshThemes();
643
+ schedulePreview();
644
+ }
645
+ if (state.docKey && key !== state.docKey) {
646
+ if (state.dirty) {
647
+ patch({ external: { revision, reason: "switch", key } });
648
+ return;
649
+ }
650
+ await loadDoc(key);
651
+ toast("模型打开了另一篇文档");
652
+ return;
653
+ }
654
+ if (!state.docKey) {
655
+ await loadDoc(key);
656
+ return;
657
+ }
658
+ if (revision <= state.revision) return;
659
+ if (state.dirty) {
660
+ patch({ external: { revision, reason: "revision" } });
661
+ return;
662
+ }
663
+ await loadDoc(key);
664
+ toast(`AI 更新了文档(rev ${revision})`);
665
+ },
666
+ async reload() {
667
+ if (state.docKey) await loadDoc(state.docKey);
668
+ },
669
+ /**
670
+ * 「刷新」按钮走这条路:先落盘再重载。
671
+ * 直接 reload 会把用户还没保存的输入丢掉——这是那个按钮最容易咬人的地方。
672
+ */
673
+ async reloadSafely() {
674
+ if (!state.docKey) return;
675
+ if (state.conflict) {
676
+ toast("先处理上方的冲突再刷新", "error");
677
+ return;
678
+ }
679
+ if (state.dirty) {
680
+ await saveNow();
681
+ if (state.dirty) {
682
+ toast("保存没成功,已取消刷新以免丢字", "error");
683
+ return;
684
+ }
685
+ }
686
+ await loadDoc(state.docKey);
687
+ toast("已重新载入");
688
+ },
689
+ /** 采用外部版本(放弃自己的未保存改动)——换文档时切到新文档。 */
690
+ async acceptExternal() {
691
+ const target = state.external?.key || state.docKey;
692
+ if (!target) return;
693
+ await loadDoc(target);
694
+ },
695
+ toast,
696
+ flush() {
697
+ if (saveTimer) clearTimeout(saveTimer);
698
+ if (state.external) patch({ revision: state.external.revision, external: null });
699
+ return saveNow();
700
+ }
701
+ };
702
+ return {
703
+ getSnapshot: () => state,
704
+ subscribe: (fn) => {
705
+ listeners.add(fn);
706
+ return () => listeners.delete(fn);
707
+ },
708
+ actions,
709
+ dispose() {
710
+ if (saveTimer) clearTimeout(saveTimer);
711
+ if (previewTimer) clearTimeout(previewTimer);
712
+ for (const timer of toastTimers) clearTimeout(timer);
713
+ toastTimers.clear();
714
+ imageCache.clear();
715
+ listeners.clear();
716
+ }
717
+ };
718
+ }
719
+
720
+ // client/sanitize.ts
721
+ var DROP_TAGS = /* @__PURE__ */ new Set([
722
+ "SCRIPT",
723
+ "IFRAME",
724
+ "OBJECT",
725
+ "EMBED",
726
+ "LINK",
727
+ "META",
728
+ "STYLE",
729
+ "BASE",
730
+ "TEMPLATE",
731
+ "ANIMATE",
732
+ "ANIMATEMOTION",
733
+ "ANIMATETRANSFORM",
734
+ "SET",
735
+ "DISCARD"
736
+ ]);
737
+ var URL_ATTRS = /* @__PURE__ */ new Set(["href", "src", "xlink:href", "srcset", "poster", "formaction", "action", "data", "background", "dynsrc", "lowsrc"]);
738
+ var RASTER_DATA_URI_RE = /^data:image\/(?:png|jpe?g|gif|webp|bmp)[;,]/i;
739
+ function normalizeUrl(value) {
740
+ return String(value ?? "").replace(/[\u0000-\u0020\u007f-\u009f]+/g, "").toLowerCase();
741
+ }
742
+ function isDangerousUrl(value) {
743
+ const v = normalizeUrl(value);
744
+ if (/^(?:javascript|vbscript):/.test(v)) return true;
745
+ if (v.startsWith("data:")) return !RASTER_DATA_URI_RE.test(v);
746
+ return false;
747
+ }
748
+ function attributeIsDangerous(name2, value) {
749
+ const lower = String(name2 || "").toLowerCase().replace(/^[^a-z]+/, "");
750
+ if (lower.startsWith("on") || lower === "srcdoc") return true;
751
+ if (!URL_ATTRS.has(lower)) return false;
752
+ if (lower === "srcset") {
753
+ return String(value ?? "").split(",").some((candidate) => isDangerousUrl(candidate.trim().split(/\s+/)[0] || ""));
754
+ }
755
+ return isDangerousUrl(value);
756
+ }
757
+ function sanitizeAttributes(el) {
758
+ const attrs = el.attributes;
759
+ if (!attrs || typeof el.removeAttribute !== "function") return;
760
+ for (const attr of Array.from(attrs)) {
761
+ const name2 = String(attr.name || "");
762
+ if (attributeIsDangerous(name2, String(attr.value ?? ""))) el.removeAttribute(name2);
763
+ }
764
+ }
765
+ function walk(node, drop) {
766
+ const kids = node.childNodes;
767
+ if (!kids) return;
768
+ for (let i = 0; i < kids.length; i++) {
769
+ const child = kids[i];
770
+ if (!child) continue;
771
+ if (child.nodeType === 1) {
772
+ const tag = String(child.tagName || "").toUpperCase();
773
+ if (DROP_TAGS.has(tag)) {
774
+ drop.push(child);
775
+ continue;
776
+ }
777
+ sanitizeAttributes(child);
778
+ }
779
+ walk(child, drop);
780
+ }
781
+ }
782
+ function sanitizeTree(root) {
783
+ if (!root) return;
784
+ const drop = [];
785
+ walk(root, drop);
786
+ for (const node of drop) node.remove?.();
787
+ }
788
+ function sanitizeHtmlFragment(html, parser = new DOMParser()) {
789
+ const doc = parser.parseFromString(html, "text/html");
790
+ if (!doc || !doc.body) throw new Error("这段内容没能解析成 HTML,复制已中止");
791
+ sanitizeTree(doc.body);
792
+ const fragment = document.createDocumentFragment();
793
+ for (const node of Array.from(doc.body.childNodes)) fragment.appendChild(node);
794
+ return fragment;
795
+ }
796
+
797
+ // client/keys.ts
798
+ function detectMac() {
799
+ try {
800
+ const nav = typeof navigator === "undefined" ? null : navigator;
801
+ if (!nav) return false;
802
+ const declared = String(nav.userAgentData && nav.userAgentData.platform || nav.platform || "");
803
+ if (/mac|iphone|ipad|ipod/i.test(declared)) return true;
804
+ return typeof nav.userAgent === "string" && /mac os x|iphone|ipad/i.test(nav.userAgent);
805
+ } catch {
806
+ return false;
807
+ }
808
+ }
809
+ var IS_MAC = detectMac();
810
+ var MOD_KEY = IS_MAC ? "⌘" : "Ctrl";
811
+ var COPY_HINT = IS_MAC ? "⌘⇧C" : "Ctrl+Shift+C";
812
+
813
+ // client/mdedit.ts
814
+ function lineBounds(text, start, end) {
815
+ const from = text.lastIndexOf("\n", Math.max(0, start - 1)) + 1;
816
+ let to;
817
+ if (end > from && text[end - 1] === "\n") to = end - 1;
818
+ else {
819
+ to = text.indexOf("\n", end);
820
+ if (to < 0) to = text.length;
821
+ }
822
+ return { from, to };
823
+ }
824
+ var INDENT_OF = /^[ \t]*/;
825
+ var BLOCK_PREFIX = /^(?:#{1,6}\s+|>\s?|[-*+]\s+|\d+[.)]\s+)/;
826
+ var LINE_SPECS = {
827
+ h1: { match: /^#{1}\s+/, render: () => "# " },
828
+ h2: { match: /^#{2}\s+/, render: () => "## " },
829
+ h3: { match: /^#{3}\s+/, render: () => "### " },
830
+ // 「正文」:把任意级别的标题摘掉
831
+ h0: { match: /^#{1,6}\s+/, render: () => "" },
832
+ quote: { match: /^>\s?/, render: () => "> " },
833
+ ul: { match: /^[-*+]\s+/, render: () => "- " },
834
+ ol: { match: /^\d+[.)]\s+/, render: (i) => `${i + 1}. ` }
835
+ };
836
+ var INLINE = {
837
+ bold: { mark: "**", placeholder: "加粗文字" },
838
+ italic: { mark: "*", placeholder: "斜体文字" },
839
+ strike: { mark: "~~", placeholder: "删除线" },
840
+ code: { mark: "`", placeholder: "代码" }
841
+ };
842
+ function wrapInline(text, start, end, mark, placeholder) {
843
+ const selected = text.slice(start, end);
844
+ const before = text.slice(Math.max(0, start - mark.length), start);
845
+ const after = text.slice(end, end + mark.length);
846
+ if (selected && before === mark && after === mark) {
847
+ const from = start - mark.length;
848
+ return { from, to: end + mark.length, insert: selected, start: from, end: from + selected.length };
849
+ }
850
+ if (selected.length >= mark.length * 2 && selected.startsWith(mark) && selected.endsWith(mark)) {
851
+ const inner = selected.slice(mark.length, selected.length - mark.length);
852
+ return { from: start, to: end, insert: inner, start, end: start + inner.length };
853
+ }
854
+ const body = selected || placeholder;
855
+ const insert = `${mark}${body}${mark}`;
856
+ const bodyStart = start + mark.length;
857
+ return { from: start, to: end, insert, start: bodyStart, end: bodyStart + body.length };
858
+ }
859
+ function insertLink(text, start, end) {
860
+ const selected = text.slice(start, end);
861
+ const label = selected || "文字";
862
+ const href = "链接地址";
863
+ const insert = `[${label}](${href})`;
864
+ const hrefStart = start + label.length + 3;
865
+ if (selected) {
866
+ return { from: start, to: end, insert, start: hrefStart, end: hrefStart + href.length };
867
+ }
868
+ return { from: start, to: end, insert, start: start + 1, end: start + 1 + label.length };
869
+ }
870
+ function withLinePrefix(text, start, end, spec) {
871
+ const { from, to } = lineBounds(text, start, end);
872
+ const block = text.slice(from, to);
873
+ if (!block.trim()) {
874
+ const insert2 = spec.render(0);
875
+ return { from, to, insert: insert2, start: from + insert2.length, end: from + insert2.length };
876
+ }
877
+ const lines = block.split("\n").map((line) => {
878
+ const indent = (INDENT_OF.exec(line) || [""])[0];
879
+ const raw = line.slice(indent.length);
880
+ return { indent, raw, bare: raw.replace(BLOCK_PREFIX, "") };
881
+ });
882
+ const all = lines.every((l) => spec.match.test(l.raw));
883
+ const insert = lines.map((l, i) => all ? l.indent + l.bare : l.indent + spec.render(i) + l.bare).join("\n");
884
+ return { from, to, insert, start: from, end: from + insert.length };
885
+ }
886
+ var LIST_OR_INDENT = /^(?:[ \t]+|[-*+]\s+|\d+[.)]\s+)/;
887
+ function indentSelection(text, start, end, step = " ") {
888
+ const { from, to } = lineBounds(text, start, end);
889
+ const line = text.slice(from, to);
890
+ const inList = LIST_OR_INDENT.test(line);
891
+ const multi = end > start;
892
+ if (!inList && !multi) {
893
+ return { from: start, to: end, insert: step + text.slice(start, end), start: start + step.length, end: end + step.length };
894
+ }
895
+ const lines = text.slice(from, to).split("\n");
896
+ const insert = lines.map((l) => step + l).join("\n");
897
+ return { from, to, insert, start: start + step.length, end: end + step.length * lines.length };
898
+ }
899
+ function outdentSelection(text, start, end, step = " ") {
900
+ const { from, to } = lineBounds(text, start, end);
901
+ const lines = text.slice(from, to).split("\n");
902
+ let deltaHead = 0;
903
+ let deltaAll = 0;
904
+ const out = lines.map((line, i) => {
905
+ const indent = (INDENT_OF.exec(line) || [""])[0];
906
+ if (!indent) return line;
907
+ const cut = indent.length >= step.length ? step.length : indent.length;
908
+ if (i === 0) deltaHead = cut;
909
+ deltaAll += cut;
910
+ return line.slice(cut);
911
+ });
912
+ if (!deltaAll) return null;
913
+ const insert = out.join("\n");
914
+ const nextStart = Math.max(from, start - deltaHead);
915
+ return { from, to, insert, start: nextStart, end: Math.max(nextStart, end - deltaAll) };
916
+ }
917
+ var LINE_MARKER = /^([ \t]*)([-*+]|\d+[.)]|>)\s+(.*)$/;
918
+ function continueList(text, start, end) {
919
+ const { from, to } = lineBounds(text, start, end);
920
+ const raw = text.slice(from, to);
921
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
922
+ const lineEnd = from + line.length;
923
+ const m = LINE_MARKER.exec(line);
924
+ if (!m) return null;
925
+ const [full, indent, marker, rest] = m;
926
+ const restStart = from + full.length - rest.length;
927
+ if (start < restStart) return null;
928
+ if (!rest.trim() && start >= lineEnd) {
929
+ const cut = Math.min(to, lineEnd);
930
+ if (indent.length >= 2) {
931
+ const insert2 = `${indent.slice(0, -2)}${marker} `;
932
+ return { from, to: cut, insert: insert2, start: from + insert2.length, end: from + insert2.length };
933
+ }
934
+ return { from, to: cut, insert: "", start: from, end: from };
935
+ }
936
+ const nextMarker = marker === ">" ? ">" : /^\d/.test(marker) ? `${parseInt(marker, 10) + 1}.` : marker;
937
+ const insert = `
938
+ ${indent}${nextMarker} `;
939
+ return { from: start, to: end, insert, start: start + insert.length, end: start + insert.length };
940
+ }
941
+ function applyAction(id, text, start, end) {
942
+ switch (id) {
943
+ case "bold":
944
+ case "italic":
945
+ case "strike":
946
+ case "code": {
947
+ const spec = INLINE[id];
948
+ return wrapInline(text, start, end, spec.mark, spec.placeholder);
949
+ }
950
+ case "link":
951
+ return insertLink(text, start, end);
952
+ default:
953
+ return withLinePrefix(text, start, end, LINE_SPECS[id]);
954
+ }
955
+ }
956
+ var SHORTCUTS = [
957
+ { id: "bold", label: "加粗", group: "编辑", key: "b", mod: true, scope: "editor" },
958
+ { id: "italic", label: "斜体", group: "编辑", key: "i", mod: true, scope: "editor" },
959
+ { id: "code", label: "行内代码", group: "编辑", key: "e", mod: true, scope: "editor" },
960
+ { id: "link", label: "链接", group: "编辑", key: "k", mod: true, scope: "editor" },
961
+ { id: "strike", label: "删除线", group: "编辑", key: "x", mod: true, shift: true, scope: "editor" },
962
+ { id: "h1", label: "一级标题", group: "编辑", code: "Digit1", keyLabel: "1", mod: true, alt: true, scope: "editor" },
963
+ { id: "h2", label: "二级标题", group: "编辑", code: "Digit2", keyLabel: "2", mod: true, alt: true, scope: "editor" },
964
+ { id: "h3", label: "三级标题", group: "编辑", code: "Digit3", keyLabel: "3", mod: true, alt: true, scope: "editor" },
965
+ { id: "h0", label: "正文(去掉标题)", group: "编辑", code: "Digit0", keyLabel: "0", mod: true, alt: true, scope: "editor" },
966
+ { id: "quote", label: "引用", group: "编辑", code: "Period", fallbackKey: ".", keyLabel: ".", mod: true, shift: true, scope: "editor" },
967
+ { id: "ul", label: "无序列表", group: "编辑", code: "Digit8", fallbackKey: "8", keyLabel: "8", mod: true, shift: true, scope: "editor" },
968
+ { id: "ol", label: "有序列表", group: "编辑", code: "Digit7", fallbackKey: "7", keyLabel: "7", mod: true, shift: true, scope: "editor" },
969
+ { id: "save", label: "保存", group: "面板", key: "s", mod: true, scope: "editor" },
970
+ { id: "copy", label: "复制到公众号", group: "面板", key: "c", mod: true, shift: true, scope: "editor" },
971
+ // 批注框有自己的键盘处理,这里只做速查
972
+ { id: "note", label: "添加批注", group: "面板", key: "enter", keyLabel: "Enter", mod: true, scope: "panel" }
973
+ ];
974
+ function keyMatches(spec, event) {
975
+ if (spec.code) {
976
+ if (event.code) return event.code === spec.code;
977
+ return spec.fallbackKey ? String(event.key).toLowerCase() === spec.fallbackKey : false;
978
+ }
979
+ if (!spec.key) return false;
980
+ return String(event.key).toLowerCase() === spec.key;
981
+ }
982
+ function matchShortcut(event, scope = "editor") {
983
+ const mod = !!(event.metaKey || event.ctrlKey);
984
+ for (const spec of SHORTCUTS) {
985
+ if (scope !== "all" && spec.scope !== scope) continue;
986
+ if (!!spec.mod !== mod) continue;
987
+ if (!!spec.alt !== !!event.altKey) continue;
988
+ if (!!spec.shift !== !!event.shiftKey) continue;
989
+ if (keyMatches(spec, event)) return spec;
990
+ }
991
+ return null;
992
+ }
993
+ function shortcutHint(spec, isMac) {
994
+ const key = spec.keyLabel || (spec.key ? spec.key.toUpperCase() : "");
995
+ if (isMac) return `${spec.mod ? "⌘" : ""}${spec.alt ? "⌥" : ""}${spec.shift ? "⇧" : ""}${key}`;
996
+ const parts = [];
997
+ if (spec.mod) parts.push("Ctrl");
998
+ if (spec.alt) parts.push("Alt");
999
+ if (spec.shift) parts.push("Shift");
1000
+ parts.push(key);
1001
+ return parts.join("+");
1002
+ }
1003
+
1004
+ // client/icon-names.ts
1005
+ var ICON_EXPORT_ALIASES = {
1006
+ // 主题图标(THEME_GLYPH 引用的就是这些稳定名)
1007
+ IconAgentPresetOutline16: ["IconAgentPresetOutlineRegular", "IconAgentPresetOutline16"],
1008
+ IconBranchOutline16: ["IconBranchOutlineRegular", "IconBranchOutline16"],
1009
+ IconBrowseOutline16: ["IconBrowseOutlineRegular", "IconBrowseOutline16"],
1010
+ IconCodeOutline16: ["IconCodeOutlineRegular", "IconCodeOutline16"],
1011
+ IconDarkOutline16: ["IconDarkOutlineRegular", "IconDarkOutline16"],
1012
+ IconEditOutline16: ["IconEditOutlineRegular", "IconEditOutline16"],
1013
+ IconEnhanceOutline16: ["IconEnhanceOutlineRegular", "IconEnhanceOutline16"],
1014
+ IconLightOutline16: ["IconLightOutlineRegular", "IconLightOutline16"],
1015
+ IconListPenOutline16: ["IconListPenOutlineRegular", "IconListPenOutline16"],
1016
+ IconPersonalizationOutline16: ["IconPersonalizationOutlineRegular", "IconPersonalizationOutline16"],
1017
+ IconSparkle16: ["IconSparkleRegular", "IconSparkle16"],
1018
+ IconThinkOutline16: ["IconThinkOutlineRegular", "IconThinkOutline16"],
1019
+ // 面板控件(下拉箭头 / 选中勾)
1020
+ IconChevronDownOutline14: ["IconChevronDownOutlineRegular", "IconChevronDownOutline14"],
1021
+ IconCheckOutline14: ["IconCheckOutlineRegular", "IconCheckOutline14"]
1022
+ };
1023
+ function iconExportCandidates(stable) {
1024
+ const aliases = ICON_EXPORT_ALIASES[stable];
1025
+ return aliases ? [aliases[0], aliases[1]] : [stable];
1026
+ }
1027
+ function isIconComponent(value) {
1028
+ if (typeof value === "function") return true;
1029
+ return !!value && typeof value === "object" && !!value.$$typeof;
1030
+ }
1031
+ function resolveIconExport(primitives, stable) {
1032
+ for (const name2 of iconExportCandidates(stable)) {
1033
+ let value;
1034
+ try {
1035
+ value = primitives ? primitives[name2] : void 0;
1036
+ } catch {
1037
+ continue;
1038
+ }
1039
+ if (isIconComponent(value)) return value;
1040
+ }
1041
+ return null;
1042
+ }
1043
+
1044
+ // client/icons.tsx
1045
+ var import_jsx_runtime = require("react/jsx-runtime");
1046
+ function loadPrimitives() {
1047
+ try {
1048
+ return require("@deepseek-ai/dsh-client-ui-primitives") || {};
1049
+ } catch {
1050
+ return {};
1051
+ }
1052
+ }
1053
+ var PRIMITIVES = loadPrimitives();
1054
+ function builtin(name2, props, fallbackSize) {
1055
+ const Icon = resolveIconExport(PRIMITIVES, name2);
1056
+ if (!Icon) return null;
1057
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Icon, { size: props.size ?? fallbackSize, className: props.className });
1058
+ }
1059
+ function FishGlyph({ size = 16, className }) {
1060
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("svg", { width: size, height: size, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", className, children: [
1061
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1062
+ "path",
1063
+ {
1064
+ d: "M2.1 8C3.2 5.6 5.4 4.1 8.2 4.1C10.1 4.1 11.5 5 12.4 6.2L14.6 4.4L13.3 8L14.6 11.6L12.4 9.8C11.5 11 10.1 11.9 8.2 11.9C5.4 11.9 3.2 10.4 2.1 8Z",
1065
+ stroke: "currentColor",
1066
+ strokeWidth: "1.05",
1067
+ strokeLinejoin: "round",
1068
+ strokeLinecap: "round"
1069
+ }
1070
+ ),
1071
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("circle", { cx: "4.9", cy: "7.2", r: "0.75", fill: "currentColor" })
1072
+ ] });
1073
+ }
1074
+ var THEME_GLYPH = {
1075
+ default: "IconListPenOutline16",
1076
+ // 排版:默认公众号就是给微信做的那套
1077
+ elegant: "IconSparkle16",
1078
+ // 优雅简约
1079
+ deep_read: "IconThinkOutline16",
1080
+ // 深度阅读
1081
+ nyt: "IconBrowseOutline16",
1082
+ // 纽约时报(版面)
1083
+ apple: "IconLightOutline16",
1084
+ // Apple 极简(明亮、留白)
1085
+ claude: "IconAgentPresetOutline16",
1086
+ // Claude(对话助手)
1087
+ sspai: "IconPersonalizationOutline16",
1088
+ // 少数派(个性)
1089
+ bamboo: "IconBranchOutline16",
1090
+ // 竹林(枝叶)
1091
+ tech: "IconCodeOutline16",
1092
+ // 技术风格(代码)
1093
+ dark_night: "IconDarkOutline16",
1094
+ // 暗夜模式
1095
+ gradient: "IconEnhanceOutline16",
1096
+ // 渐变彩虹(增色)
1097
+ // 「自定义主题」:工作目录里那**一套**模型生成的主题,图标固定这一个。
1098
+ // 为什么不让模型挑:合法图标名的名单只在浏览器这半(`icon-names.ts` 的对照表),而校验器在宿主那半——
1099
+ // 让模型选就得在宿主再抄一份、跟着 DSH 升级维护,抄漏一个的后果是"图标静默消失且不报错"。
1100
+ custom: "IconEditOutline16"
1101
+ };
1102
+ function ThemeGlyph({ themeKey, size = 16, className }) {
1103
+ return builtin(THEME_GLYPH[themeKey] || "", { size, className }, 16);
1104
+ }
1105
+ function CaretGlyph({ size = 14, className }) {
1106
+ return builtin("IconChevronDownOutline14", { size, className }, 14) ?? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className, children: "▾" });
1107
+ }
1108
+ function TickGlyph({ size = 14, className }) {
1109
+ return builtin("IconCheckOutline14", { size, className }, 14) ?? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className, children: "✓" });
1110
+ }
1111
+
1112
+ // client/panel.tsx
1113
+ var import_jsx_runtime2 = require("react/jsx-runtime");
1114
+ var READING_CHARS_PER_MIN = 400;
1115
+ function countWords(text) {
1116
+ const cjk = (text.match(/[\u3400-\u9fff\u3040-\u30ff]/g) || []).length;
1117
+ const latin = (text.match(/[A-Za-z0-9]+/g) || []).length;
1118
+ return cjk + latin;
1119
+ }
1120
+ function readingMinutes(text) {
1121
+ return Math.max(1, Math.round(countWords(text) / READING_CHARS_PER_MIN));
1122
+ }
1123
+ function fmtTime(at) {
1124
+ const d = new Date(at);
1125
+ const pad = (n) => String(n).padStart(2, "0");
1126
+ return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
1127
+ }
1128
+ var KIND_LABEL = {
1129
+ heading: "标题",
1130
+ paragraph: "正文",
1131
+ list: "列表",
1132
+ blockquote: "引用",
1133
+ table: "表格",
1134
+ code: "代码块",
1135
+ card: "信息卡片",
1136
+ hr: "分隔线",
1137
+ html: "原始 HTML"
1138
+ };
1139
+ function kindLabel(kind, fallback = "块") {
1140
+ if (!kind) return fallback;
1141
+ return KIND_LABEL[kind] || kind;
1142
+ }
1143
+ var FONT_LABEL = { sans: "黑体", serif: "衬线", mono: "等宽" };
1144
+ async function copyRich(html, plain) {
1145
+ const ClipboardItemCtor = globalThis.ClipboardItem;
1146
+ if (navigator.clipboard && ClipboardItemCtor) {
1147
+ await navigator.clipboard.write([
1148
+ new ClipboardItemCtor({
1149
+ "text/html": new Blob([html], { type: "text/html" }),
1150
+ "text/plain": new Blob([plain], { type: "text/plain" })
1151
+ })
1152
+ ]);
1153
+ return;
1154
+ }
1155
+ const holder = document.createElement("div");
1156
+ holder.contentEditable = "true";
1157
+ holder.style.position = "fixed";
1158
+ holder.style.left = "-9999px";
1159
+ holder.replaceChildren(sanitizeHtmlFragment(html));
1160
+ document.body.appendChild(holder);
1161
+ try {
1162
+ const range = document.createRange();
1163
+ range.selectNodeContents(holder);
1164
+ const selection = window.getSelection();
1165
+ selection?.removeAllRanges();
1166
+ selection?.addRange(range);
1167
+ document.execCommand("copy");
1168
+ selection?.removeAllRanges();
1169
+ } finally {
1170
+ holder.remove();
1171
+ }
1172
+ }
1173
+ function download(name2, html) {
1174
+ const url = URL.createObjectURL(new Blob([html], { type: "text/html;charset=utf-8" }));
1175
+ const a = document.createElement("a");
1176
+ a.href = url;
1177
+ a.download = name2;
1178
+ document.body.appendChild(a);
1179
+ a.click();
1180
+ a.remove();
1181
+ setTimeout(() => URL.revokeObjectURL(url), 4e3);
1182
+ }
1183
+ function imageFiles(data) {
1184
+ if (!data) return [];
1185
+ const out = [];
1186
+ const items = data.items;
1187
+ if (items) {
1188
+ for (let i = 0; i < items.length; i++) {
1189
+ const item = items[i];
1190
+ if (item.kind !== "file" || !/^image\//i.test(item.type)) continue;
1191
+ const file = item.getAsFile();
1192
+ if (file) out.push(file);
1193
+ }
1194
+ }
1195
+ if (!out.length && data.files) {
1196
+ for (let i = 0; i < data.files.length; i++) {
1197
+ const file = data.files[i];
1198
+ if (/^image\//i.test(file.type)) out.push(file);
1199
+ }
1200
+ }
1201
+ return out;
1202
+ }
1203
+ function Btn(props) {
1204
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1205
+ "button",
1206
+ {
1207
+ type: "button",
1208
+ className: `fp-btn${props.primary ? " fp-btn-primary" : ""}`,
1209
+ "data-on": props.on ? "true" : void 0,
1210
+ disabled: props.disabled,
1211
+ title: props.title,
1212
+ onClick: props.onClick,
1213
+ children: props.children
1214
+ }
1215
+ );
1216
+ }
1217
+ function BlankMark() {
1218
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-mark", children: [
1219
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-blank-glyph", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(FishGlyph, { size: 15 }) }),
1220
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: "鱼排编辑器" })
1221
+ ] });
1222
+ }
1223
+ function ThemePicker(props) {
1224
+ const { themes, value } = props;
1225
+ const [open, setOpen] = React.useState(false);
1226
+ const [active, setActive] = React.useState(0);
1227
+ const wrapRef = React.useRef(null);
1228
+ const menuRef = React.useRef(null);
1229
+ const btnRef = React.useRef(null);
1230
+ const customGroup = React.useMemo(() => {
1231
+ const custom = themes.filter((t) => t.key === "custom");
1232
+ if (!custom.length) return [];
1233
+ return [
1234
+ {
1235
+ key: "custom",
1236
+ label: "我的",
1237
+ note: custom[0].wechatSafe ? "" : "微信可能掉样式",
1238
+ risk: !custom[0].wechatSafe,
1239
+ items: custom
1240
+ }
1241
+ ];
1242
+ }, [themes]);
1243
+ const groups = React.useMemo(
1244
+ () => [
1245
+ ...customGroup,
1246
+ { key: "safe", label: "适合公众号", note: "", risk: false, items: themes.filter((t) => t.key !== "custom" && t.wechatSafe) },
1247
+ { key: "risky", label: "其它风格", note: "微信可能掉样式", risk: true, items: themes.filter((t) => t.key !== "custom" && !t.wechatSafe) }
1248
+ ],
1249
+ [themes, customGroup]
1250
+ );
1251
+ const flat = React.useMemo(() => groups.flatMap((g) => g.items), [groups]);
1252
+ const current = flat.find((t) => t.key === value) || null;
1253
+ React.useEffect(() => {
1254
+ if (!open) return;
1255
+ const onDown = (event) => {
1256
+ if (!wrapRef.current || !wrapRef.current.contains(event.target)) setOpen(false);
1257
+ };
1258
+ document.addEventListener("mousedown", onDown);
1259
+ return () => document.removeEventListener("mousedown", onDown);
1260
+ }, [open]);
1261
+ const openMenu = () => {
1262
+ const index = flat.findIndex((t) => t.key === value);
1263
+ setActive(index < 0 ? 0 : index);
1264
+ setOpen(true);
1265
+ };
1266
+ const closeMenu = (refocus = false) => {
1267
+ setOpen(false);
1268
+ if (refocus) btnRef.current?.focus();
1269
+ };
1270
+ const commit = (key) => {
1271
+ if (key) props.onPick(key);
1272
+ closeMenu(true);
1273
+ };
1274
+ React.useEffect(() => {
1275
+ if (open) menuRef.current?.focus();
1276
+ }, [open]);
1277
+ const activeKey = flat[active] ? flat[active].key : void 0;
1278
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-picker", ref: wrapRef, children: [
1279
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1280
+ "button",
1281
+ {
1282
+ ref: btnRef,
1283
+ type: "button",
1284
+ className: "fp-picker-btn",
1285
+ "aria-haspopup": "listbox",
1286
+ "aria-expanded": open,
1287
+ "aria-label": "主题",
1288
+ title: "主题:默认公众号是给微信做的;其它风格更适合导出 HTML",
1289
+ onClick: () => open ? setOpen(false) : openMenu(),
1290
+ onKeyDown: (event) => {
1291
+ if (open) return;
1292
+ if (event.key === "Escape" || event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
1293
+ event.preventDefault();
1294
+ openMenu();
1295
+ }
1296
+ },
1297
+ children: [
1298
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ThemeGlyph, { themeKey: value, size: 14, className: "fp-picker-glyph" }),
1299
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-picker-label", children: current ? current.name : value }),
1300
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CaretGlyph, { className: "fp-picker-caret" })
1301
+ ]
1302
+ }
1303
+ ),
1304
+ open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1305
+ "div",
1306
+ {
1307
+ className: "fp-menu",
1308
+ role: "listbox",
1309
+ "aria-label": "主题",
1310
+ ref: menuRef,
1311
+ tabIndex: -1,
1312
+ "aria-activedescendant": activeKey ? `fp-theme-opt-${activeKey}` : void 0,
1313
+ onKeyDown: (event) => {
1314
+ if (event.key === "Escape") {
1315
+ event.preventDefault();
1316
+ closeMenu(true);
1317
+ return;
1318
+ }
1319
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1320
+ event.preventDefault();
1321
+ const step = event.key === "ArrowDown" ? 1 : -1;
1322
+ setActive((index) => flat.length ? (index + step + flat.length) % flat.length : 0);
1323
+ return;
1324
+ }
1325
+ if (event.key === "Home" || event.key === "End") {
1326
+ event.preventDefault();
1327
+ setActive(event.key === "Home" ? 0 : Math.max(0, flat.length - 1));
1328
+ return;
1329
+ }
1330
+ if (event.key === "Enter") {
1331
+ event.preventDefault();
1332
+ commit(activeKey);
1333
+ }
1334
+ },
1335
+ children: groups.map((group) => (
1336
+ // role="presentation":分组标题是排版用的,`<div>` 直接坐在 role="listbox" 里
1337
+ // 属于非法子节点(listbox 只允许 option 与分组)
1338
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { role: "presentation", children: [
1339
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-menu-group", role: "presentation", "data-risk": group.risk ? "true" : void 0, children: [
1340
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-group-title", children: group.label }),
1341
+ group.note ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-group-note", children: group.note }) : null,
1342
+ group.key === "custom" && props.onDeleteCustom ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1343
+ "button",
1344
+ {
1345
+ type: "button",
1346
+ className: "fp-menu-x",
1347
+ "aria-label": "删除自定义主题",
1348
+ title: "删掉这一套自定义主题(当前文档会退回「默认公众号」)",
1349
+ onClick: (event) => {
1350
+ event.stopPropagation();
1351
+ if (window.confirm("删掉「自定义主题」?当前文档会退回「默认公众号」。")) {
1352
+ props.onDeleteCustom?.();
1353
+ }
1354
+ },
1355
+ children: "×"
1356
+ }
1357
+ ) : null
1358
+ ] }),
1359
+ group.items.map((theme) => {
1360
+ const index = flat.indexOf(theme);
1361
+ const on = theme.key === value;
1362
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1363
+ "button",
1364
+ {
1365
+ id: `fp-theme-opt-${theme.key}`,
1366
+ type: "button",
1367
+ role: "option",
1368
+ tabIndex: -1,
1369
+ "aria-selected": on,
1370
+ className: "fp-menu-row",
1371
+ "data-on": on ? "true" : void 0,
1372
+ "data-active": index === active ? "true" : void 0,
1373
+ title: theme.desc,
1374
+ onMouseEnter: () => setActive(index),
1375
+ onClick: () => commit(theme.key),
1376
+ children: [
1377
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ThemeGlyph, { themeKey: theme.key, size: 14, className: "fp-menu-glyph" }),
1378
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-name", children: theme.name }),
1379
+ on ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TickGlyph, { className: "fp-menu-tick" }) : null
1380
+ ]
1381
+ },
1382
+ theme.key
1383
+ );
1384
+ })
1385
+ ] }, group.key)
1386
+ ))
1387
+ }
1388
+ ) : null
1389
+ ] });
1390
+ }
1391
+ function FontPicker(props) {
1392
+ const { fonts, value } = props;
1393
+ const [open, setOpen] = React.useState(false);
1394
+ const [active, setActive] = React.useState(0);
1395
+ const wrapRef = React.useRef(null);
1396
+ const menuRef = React.useRef(null);
1397
+ const btnRef = React.useRef(null);
1398
+ const groups = React.useMemo(() => {
1399
+ const safe = fonts.filter((f) => f === "sans");
1400
+ const exportOnly = fonts.filter((f) => f !== "sans");
1401
+ return [
1402
+ { key: "safe", label: "", note: "", risk: false, items: safe },
1403
+ { key: "export", label: "", note: "仅用于导出 HTML", risk: true, items: exportOnly }
1404
+ ].filter((g) => g.items.length);
1405
+ }, [fonts]);
1406
+ const flat = React.useMemo(() => groups.flatMap((g) => g.items), [groups]);
1407
+ const current = flat.includes(value) ? value : flat[0];
1408
+ React.useEffect(() => {
1409
+ if (!open) return;
1410
+ const onDown = (event) => {
1411
+ if (!wrapRef.current || !wrapRef.current.contains(event.target)) setOpen(false);
1412
+ };
1413
+ document.addEventListener("mousedown", onDown);
1414
+ return () => document.removeEventListener("mousedown", onDown);
1415
+ }, [open]);
1416
+ const openMenu = () => {
1417
+ const index = flat.indexOf(current);
1418
+ setActive(index < 0 ? 0 : index);
1419
+ setOpen(true);
1420
+ };
1421
+ const closeMenu = (refocus = false) => {
1422
+ setOpen(false);
1423
+ if (refocus) btnRef.current?.focus();
1424
+ };
1425
+ const commit = (font) => {
1426
+ if (font) props.onPick(font);
1427
+ closeMenu(true);
1428
+ };
1429
+ React.useEffect(() => {
1430
+ if (open) menuRef.current?.focus();
1431
+ }, [open]);
1432
+ const activeFont = flat[active];
1433
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-picker", ref: wrapRef, children: [
1434
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1435
+ "button",
1436
+ {
1437
+ ref: btnRef,
1438
+ type: "button",
1439
+ className: "fp-picker-btn",
1440
+ "aria-haspopup": "listbox",
1441
+ "aria-expanded": open,
1442
+ "aria-label": "字体",
1443
+ title: "正文字体:公众号编辑器只认黑体(iOS 设备上才是苹方),衬线/等宽只在「导出 HTML」里有效;它会覆盖主题自带的字体栈(站点既有行为,不是 bug)",
1444
+ onClick: () => open ? setOpen(false) : openMenu(),
1445
+ onKeyDown: (event) => {
1446
+ if (open) return;
1447
+ if (event.key === "Escape" || event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") {
1448
+ event.preventDefault();
1449
+ openMenu();
1450
+ }
1451
+ },
1452
+ children: [
1453
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-picker-label", children: FONT_LABEL[current] || current }),
1454
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(CaretGlyph, { className: "fp-picker-caret" })
1455
+ ]
1456
+ }
1457
+ ),
1458
+ open ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1459
+ "div",
1460
+ {
1461
+ className: "fp-menu",
1462
+ role: "listbox",
1463
+ "aria-label": "字体",
1464
+ ref: menuRef,
1465
+ tabIndex: -1,
1466
+ "aria-activedescendant": activeFont ? `fp-font-opt-${activeFont}` : void 0,
1467
+ onKeyDown: (event) => {
1468
+ if (event.key === "Escape") {
1469
+ event.preventDefault();
1470
+ closeMenu(true);
1471
+ return;
1472
+ }
1473
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1474
+ event.preventDefault();
1475
+ const step = event.key === "ArrowDown" ? 1 : -1;
1476
+ setActive((index) => flat.length ? (index + step + flat.length) % flat.length : 0);
1477
+ return;
1478
+ }
1479
+ if (event.key === "Home" || event.key === "End") {
1480
+ event.preventDefault();
1481
+ setActive(event.key === "Home" ? 0 : Math.max(0, flat.length - 1));
1482
+ return;
1483
+ }
1484
+ if (event.key === "Enter") {
1485
+ event.preventDefault();
1486
+ commit(activeFont);
1487
+ }
1488
+ },
1489
+ children: groups.map((group) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { role: "presentation", children: [
1490
+ group.label || group.note ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-menu-group", role: "presentation", "data-risk": group.risk ? "true" : void 0, children: [
1491
+ group.label ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-group-title", children: group.label }) : null,
1492
+ group.note ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-group-note", children: group.note }) : null
1493
+ ] }) : null,
1494
+ group.items.map((font) => {
1495
+ const index = flat.indexOf(font);
1496
+ const on = font === value;
1497
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1498
+ "button",
1499
+ {
1500
+ id: `fp-font-opt-${font}`,
1501
+ type: "button",
1502
+ role: "option",
1503
+ tabIndex: -1,
1504
+ "aria-selected": on,
1505
+ className: "fp-menu-row",
1506
+ "data-on": on ? "true" : void 0,
1507
+ "data-active": index === active ? "true" : void 0,
1508
+ title: font === "sans" ? "公众号编辑器里能正常显示" : "只在「导出 HTML」里有效,粘进公众号会退回黑体",
1509
+ onMouseEnter: () => setActive(index),
1510
+ onClick: () => commit(font),
1511
+ children: [
1512
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-menu-name", children: FONT_LABEL[font] || font }),
1513
+ on ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(TickGlyph, { className: "fp-menu-tick" }) : null
1514
+ ]
1515
+ },
1516
+ font
1517
+ );
1518
+ })
1519
+ ] }, group.key))
1520
+ }
1521
+ ) : null
1522
+ ] });
1523
+ }
1524
+ function Panel(props) {
1525
+ const { store, sessionId } = props;
1526
+ const state = (0, import_react.useSyncExternalStore)(store.subscribe, store.getSnapshot);
1527
+ const [mode, setMode] = React.useState("side");
1528
+ const [tab, setTab] = React.useState("notes");
1529
+ const [noteDraft, setNoteDraft] = React.useState("");
1530
+ const [narrow, setNarrow] = React.useState(false);
1531
+ const [riskDismissed, setRiskDismissed] = React.useState(null);
1532
+ const rootRef = React.useRef(null);
1533
+ const iframeRef = React.useRef(null);
1534
+ const editorRef = React.useRef(null);
1535
+ const lastVisible = React.useRef(null);
1536
+ const noteInputRef = React.useRef(null);
1537
+ React.useEffect(() => {
1538
+ void store.actions.init();
1539
+ }, [store]);
1540
+ React.useEffect(() => {
1541
+ setRiskDismissed(null);
1542
+ }, [state.meta.theme]);
1543
+ React.useEffect(() => {
1544
+ const el = rootRef.current;
1545
+ if (!el || typeof ResizeObserver === "undefined") return;
1546
+ const ro = new ResizeObserver(() => setNarrow(el.clientWidth < 560));
1547
+ ro.observe(el);
1548
+ setNarrow(el.clientWidth < 560);
1549
+ return () => ro.disconnect();
1550
+ }, []);
1551
+ React.useEffect(() => {
1552
+ const onMessage = (event) => {
1553
+ if (event.source !== iframeRef.current?.contentWindow) return;
1554
+ const data = event.data;
1555
+ if (data && data.fishpai === "visible") lastVisible.current = data.id || null;
1556
+ };
1557
+ window.addEventListener("message", onMessage);
1558
+ return () => window.removeEventListener("message", onMessage);
1559
+ }, []);
1560
+ const restoreScroll = React.useCallback(() => {
1561
+ const id = lastVisible.current;
1562
+ if (!id) return;
1563
+ iframeRef.current?.contentWindow?.postMessage({ fishpai: "reveal", id }, "*");
1564
+ }, []);
1565
+ const [pendingJump, setPendingJump] = React.useState(null);
1566
+ const applyJump = React.useCallback(
1567
+ (target) => {
1568
+ const el = editorRef.current;
1569
+ if (!el) return false;
1570
+ el.focus();
1571
+ el.setSelectionRange(target.start, target.end);
1572
+ store.actions.setCaret(target.line);
1573
+ el.scrollTop = target.start / Math.max(1, state.markdown.length) * el.scrollHeight;
1574
+ return true;
1575
+ },
1576
+ [store, state.markdown.length]
1577
+ );
1578
+ const jumpTo = React.useCallback(
1579
+ (target) => {
1580
+ if (applyJump(target)) return;
1581
+ setPendingJump(target);
1582
+ setMode((current) => current === "preview" ? narrow ? "edit" : "side" : current);
1583
+ },
1584
+ [applyJump, narrow]
1585
+ );
1586
+ React.useEffect(() => {
1587
+ if (!pendingJump) return;
1588
+ if (applyJump(pendingJump)) setPendingJump(null);
1589
+ }, [pendingJump, mode, applyJump]);
1590
+ const lineOffset = (line) => {
1591
+ const lines = state.markdown.split("\n");
1592
+ return lines.slice(0, Math.max(0, line - 1)).reduce((acc, l) => acc + l.length + 1, 0);
1593
+ };
1594
+ const jumpToLine = (line) => {
1595
+ const offset = lineOffset(line);
1596
+ jumpTo({ start: offset, end: offset, line });
1597
+ };
1598
+ const jumpToNote = (note) => {
1599
+ const markdown = state.markdown;
1600
+ const quote = note.quote || "";
1601
+ const start = quote ? markdown.indexOf(quote) : -1;
1602
+ if (start >= 0) {
1603
+ jumpTo({ start, end: start + quote.length, line: markdown.slice(0, start).split("\n").length });
1604
+ return;
1605
+ }
1606
+ const block = note.blockIndex === null || note.blockIndex === void 0 ? null : state.blocks.find((b) => b.index === note.blockIndex) || state.blocks[note.blockIndex] || null;
1607
+ if (block) {
1608
+ jumpToLine(block.startLine);
1609
+ store.actions.toast("引用片段已被改写,已定位到它原来所在的块");
1610
+ return;
1611
+ }
1612
+ store.actions.toast("这条批注引用的文字已经不在正文里了", "error");
1613
+ };
1614
+ const layout = mode === "side" && !narrow ? "side" : "stack";
1615
+ const showEditor = mode !== "preview";
1616
+ const showPreview = mode !== "edit" || layout === "side";
1617
+ const openNotes = state.notes.filter((n) => !n.resolved);
1618
+ const currentTheme = state.themes.find((t) => t.key === state.meta.theme) || null;
1619
+ const docHasLinks = state.linkCount !== 0;
1620
+ const docHasCode = state.hasCode !== false;
1621
+ const manualImages = state.images.filter((i) => !i.embed);
1622
+ const onCopy = async () => {
1623
+ if (state.placeholders.length) {
1624
+ const okToCopy = window.confirm(`还有 ${state.placeholders.length} 处「待补」占位没处理,仍要复制吗?`);
1625
+ if (!okToCopy) return;
1626
+ }
1627
+ try {
1628
+ const html = await store.actions.publishHtml();
1629
+ await copyRich(html, state.markdown);
1630
+ store.actions.toast("已复制到剪贴板:粘进公众号编辑器即可");
1631
+ } catch (error) {
1632
+ store.actions.toast(`复制失败:${error.message} 可以改用旁边的「导出 HTML」拿文件`, "error");
1633
+ }
1634
+ };
1635
+ const onExport = async () => {
1636
+ try {
1637
+ const html = await store.actions.publishHtml();
1638
+ const base = (state.title || "article").replace(/[\\/:*?"<>|]/g, "");
1639
+ download(`${base}.html`, html);
1640
+ store.actions.toast(`已导出 ${base}.html`);
1641
+ } catch (error) {
1642
+ store.actions.toast(`导出失败:${error.message} 可以改用「复制到公众号」`, "error");
1643
+ }
1644
+ };
1645
+ const onAddNote = async () => {
1646
+ const text = noteDraft.trim();
1647
+ if (!text) return;
1648
+ const added = await store.actions.addNote(text);
1649
+ if (added) setNoteDraft("");
1650
+ };
1651
+ if (state.status === "loading") {
1652
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-root", ref: rootRef, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-card", children: [
1653
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BlankMark, {}),
1654
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank-line fp-muted", children: "正在打开文档…" }),
1655
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-loading-bar" })
1656
+ ] }) }) });
1657
+ }
1658
+ if (state.status === "error" && !state.docKey) {
1659
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-root", ref: rootRef, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-card", children: [
1660
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BlankMark, {}),
1661
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-line", children: [
1662
+ "面板没能载入:",
1663
+ state.error
1664
+ ] }),
1665
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank-actions", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, onClick: () => void store.actions.init(), children: "重试" }) })
1666
+ ] }) }) });
1667
+ }
1668
+ if (state.status === "empty" || !state.docKey) {
1669
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-root", ref: rootRef, children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-card", children: [
1670
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(BlankMark, {}),
1671
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-blank-line fp-muted", children: [
1672
+ "这个会话还没有鱼排文档。可以让模型调用 ",
1673
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("code", { children: "fishpai_open" }),
1674
+ " 打开一篇 Markdown, 也可以自己先起一篇:"
1675
+ ] }),
1676
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-blank-actions", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, onClick: () => void store.actions.createDoc(), children: "新建空白文档" }) }),
1677
+ state.docs.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-doclist", children: [
1678
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-doclist-head", children: "最近打开" }),
1679
+ state.docs.slice(0, 6).map((d) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1680
+ "button",
1681
+ {
1682
+ type: "button",
1683
+ className: "fp-docrow",
1684
+ title: d.path,
1685
+ onClick: () => void store.actions.openDocByKey(d.key),
1686
+ children: [
1687
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-docrow-title", children: d.title || "未命名" }),
1688
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1689
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-muted", children: fmtTime(d.updatedAt) })
1690
+ ]
1691
+ },
1692
+ d.key
1693
+ ))
1694
+ ] }) : null
1695
+ ] }) }) });
1696
+ }
1697
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-root", "data-narrow": narrow ? "true" : "false", ref: rootRef, children: [
1698
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-toolbar", children: [
1699
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-row", children: [
1700
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-seg", children: [
1701
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { on: mode === "edit", onClick: () => setMode("edit"), title: "只看源码", children: "编辑" }),
1702
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { on: mode === "preview", onClick: () => setMode("preview"), title: "只看公众号效果", children: "预览" }),
1703
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { on: mode === "side", disabled: narrow, onClick: () => setMode("side"), title: narrow ? "栏位太窄,拉宽后可用并排" : "左右并排", children: "并排" })
1704
+ ] }),
1705
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1706
+ Btn,
1707
+ {
1708
+ on: state.meta.mobile,
1709
+ disabled: mode === "edit",
1710
+ onClick: () => store.actions.setMeta({ mobile: !state.meta.mobile }),
1711
+ title: mode === "edit" ? "现在是「编辑」视图,看不到预览;切到「预览」或「并排」才看得出手机宽度的效果" : "按手机宽度预览(375px)",
1712
+ children: "手机"
1713
+ }
1714
+ ),
1715
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1716
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fp-actions", children: [
1717
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { title: "重新载入文档(有未保存的改动会先保存,不会丢字)", onClick: () => void store.actions.reloadSafely(), children: "刷新" }),
1718
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, title: "下载「复制到公众号」形态的自包含 HTML 文件(本地图片已内嵌 base64)", onClick: () => void onExport(), children: "导出 HTML" }),
1719
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, title: `复制后直接粘进公众号编辑器(${COPY_HINT})`, onClick: () => void onCopy(), children: "复制到公众号" })
1720
+ ] })
1721
+ ] }),
1722
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-row", children: [
1723
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1724
+ ThemePicker,
1725
+ {
1726
+ themes: state.themes,
1727
+ value: state.meta.theme,
1728
+ onPick: (key) => void store.actions.setMeta({ theme: key }),
1729
+ onDeleteCustom: () => void store.actions.clearCustomTheme()
1730
+ }
1731
+ ),
1732
+ currentTheme?.usesAccent ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-swatches", title: "主题色", children: [
1733
+ state.presets.map((p) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1734
+ "button",
1735
+ {
1736
+ type: "button",
1737
+ className: "fp-swatch",
1738
+ style: { background: p.color },
1739
+ "data-on": state.meta.color === p.color ? "true" : void 0,
1740
+ title: `${p.name} ${p.color}`,
1741
+ onClick: () => void store.actions.setMeta({ color: state.meta.color === p.color ? null : p.color })
1742
+ },
1743
+ p.color
1744
+ )),
1745
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1746
+ "input",
1747
+ {
1748
+ className: "fp-color",
1749
+ type: "color",
1750
+ title: "自定义主题色",
1751
+ value: state.meta.color || "#4f6ef7",
1752
+ onChange: (e) => void store.actions.setMeta({ color: e.target.value })
1753
+ }
1754
+ )
1755
+ ] }) : null,
1756
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1757
+ FontPicker,
1758
+ {
1759
+ fonts: state.fonts,
1760
+ value: state.meta.font,
1761
+ onPick: (font) => void store.actions.setMeta({ font })
1762
+ }
1763
+ ),
1764
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1765
+ "select",
1766
+ {
1767
+ className: "fp-select",
1768
+ value: state.meta.fontSize,
1769
+ title: "正文字号",
1770
+ onChange: (e) => void store.actions.setMeta({ fontSize: e.target.value }),
1771
+ children: state.sizes.map((s) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: s, children: s }, s))
1772
+ }
1773
+ ),
1774
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1775
+ Btn,
1776
+ {
1777
+ on: state.meta.footnotes,
1778
+ disabled: !docHasLinks,
1779
+ title: docHasLinks ? "正文外链转文末「参考资料」(微信正文不支持外链)" : "正文里没有外链,这个开关现在不影响效果;它的作用是:把正文外链转成文末「参考资料」(微信正文不支持外链)",
1780
+ onClick: () => void store.actions.setMeta({ footnotes: !state.meta.footnotes }),
1781
+ children: "脚注"
1782
+ }
1783
+ ),
1784
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1785
+ Btn,
1786
+ {
1787
+ on: state.meta.macCodeBlock,
1788
+ disabled: !docHasCode,
1789
+ title: docHasCode ? "代码块用 mac 标题栏形态(红黄绿圆点 + 语言标签)" : "正文里没有代码块,这个开关现在不影响效果;它的作用是:给代码块加 mac 标题栏形态",
1790
+ onClick: () => void store.actions.setMeta({ macCodeBlock: !state.meta.macCodeBlock }),
1791
+ children: "Mac 代码框"
1792
+ }
1793
+ ),
1794
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1795
+ Btn,
1796
+ {
1797
+ title: "在下方「批注」里写一句给模型的话(会挂在光标所在段落上)",
1798
+ onClick: () => {
1799
+ setTab("notes");
1800
+ requestAnimationFrame(() => noteInputRef.current?.focus());
1801
+ },
1802
+ children: "+批注"
1803
+ }
1804
+ )
1805
+ ] })
1806
+ ] }),
1807
+ currentTheme && !currentTheme.wechatSafe && riskDismissed !== currentTheme.key ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-banner", "data-kind": "warn", children: [
1808
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1809
+ currentTheme.gradientText ? `「${currentTheme.name}」用渐变文字(background-clip: text + 透明字色),微信编辑器可能丢掉渐变导致标题异常。` : `「${currentTheme.name}」是深色底,粘进公众号会是一整块深色。`,
1810
+ "建议用「导出」拿 HTML,或换回「默认公众号」。"
1811
+ ] }),
1812
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1813
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1814
+ "button",
1815
+ {
1816
+ type: "button",
1817
+ className: "fp-banner-x",
1818
+ "aria-label": "关掉这条提示",
1819
+ title: "关掉这条提示(切回这套主题时会再出现)",
1820
+ onClick: () => setRiskDismissed(currentTheme.key),
1821
+ children: "×"
1822
+ }
1823
+ )
1824
+ ] }) : null,
1825
+ state.error ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-banner", "data-kind": "error", children: [
1826
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: state.error }),
1827
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1828
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => void store.actions.reloadSafely(), children: "重试" })
1829
+ ] }) : null,
1830
+ state.external ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-banner", children: [
1831
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: state.external.reason === "switch" ? "模型打开了另一篇文档,你这里有没保存的改动。" : `AI 更新了文档(rev ${state.external.revision}),你这里有没保存的改动。` }),
1832
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1833
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => void store.actions.acceptExternal(), children: state.external.reason === "switch" ? "切到新文档" : "看 AI 的版本" }),
1834
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => void store.actions.flush(), children: "保留我的" })
1835
+ ] }) : null,
1836
+ state.conflict ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-banner", children: [
1837
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1838
+ "文档已被改动(服务端 rev ",
1839
+ state.conflict.revision,
1840
+ "),你的编辑还在编辑器里。"
1841
+ ] }),
1842
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1843
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => void store.actions.resolveConflict("theirs"), children: "采用 AI 的(先把我的存进历史)" }),
1844
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, onClick: () => void store.actions.resolveConflict("mine"), children: "用我的覆盖" })
1845
+ ] }) : null,
1846
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-main", "data-layout": layout, children: [
1847
+ showEditor ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1848
+ Editor,
1849
+ {
1850
+ pane: true,
1851
+ editorRef,
1852
+ value: state.markdown,
1853
+ onChange: (text) => store.actions.setMarkdown(text),
1854
+ onCaret: (line) => store.actions.setCaret(line),
1855
+ onSave: () => void store.actions.flush(),
1856
+ onCopy: () => void onCopy(),
1857
+ upload: (file) => store.actions.uploadImage(file),
1858
+ toast: (text, kind) => store.actions.toast(text, kind),
1859
+ currentBlock: state.blocks.find((b) => b.startLine <= state.caretLine && state.caretLine <= b.endLine) || null
1860
+ }
1861
+ ) : null,
1862
+ showPreview ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1863
+ Preview,
1864
+ {
1865
+ pane: true,
1866
+ iframeRef,
1867
+ html: state.previewHtml,
1868
+ imageMap: state.imageMap,
1869
+ hljsCss: state.hljsCss,
1870
+ mobile: state.meta.mobile,
1871
+ previewing: state.previewing,
1872
+ themeName: state.themeName,
1873
+ onLoad: restoreScroll
1874
+ }
1875
+ ) : null
1876
+ ] }),
1877
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1878
+ Drawer,
1879
+ {
1880
+ tab,
1881
+ setTab,
1882
+ notes: state.notes,
1883
+ placeholders: state.placeholders,
1884
+ blocks: state.blocks,
1885
+ history: state.history,
1886
+ noteDraft,
1887
+ setNoteDraft,
1888
+ noteInputRef,
1889
+ onAddNote: () => void onAddNote(),
1890
+ onResolve: (id) => void store.actions.updateNote(id, { resolved: true }),
1891
+ onRemove: (id) => void store.actions.removeNote(id),
1892
+ onJump: jumpToLine,
1893
+ onJumpNote: jumpToNote,
1894
+ onRestore: (id, label) => void store.actions.restore(id, label)
1895
+ }
1896
+ ),
1897
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-status", children: [
1898
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1899
+ countWords(state.markdown),
1900
+ " 字"
1901
+ ] }),
1902
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1903
+ "约 ",
1904
+ readingMinutes(state.markdown),
1905
+ " 分钟"
1906
+ ] }),
1907
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1908
+ "rev ",
1909
+ state.revision
1910
+ ] }),
1911
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: state.saving ? "保存中…" : state.dirty ? "未保存" : "已保存" }),
1912
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1913
+ state.images.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(
1914
+ "span",
1915
+ {
1916
+ className: manualImages.length ? "fp-warn" : void 0,
1917
+ title: manualImages.length ? `这些图不会被内嵌,粘进公众号后要在编辑器里手动上传:
1918
+ ${manualImages.map((i) => i.src).join("\n")}` : "本地图会内嵌成 base64,复制到公众号时跟着一起过去,不用手动重传",
1919
+ children: [
1920
+ "图片 ",
1921
+ state.images.length,
1922
+ manualImages.length ? `(${manualImages.length} 需手动上传)` : ""
1923
+ ]
1924
+ }
1925
+ ) : null,
1926
+ state.placeholders.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fp-warn", children: [
1927
+ "待补 ",
1928
+ state.placeholders.length
1929
+ ] }) : null,
1930
+ openNotes.length ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
1931
+ "批注 ",
1932
+ openNotes.length
1933
+ ] }) : null,
1934
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fp-muted", title: currentTheme ? currentTheme.desc : void 0, children: [
1935
+ state.themeName,
1936
+ currentTheme ? ` · ${currentTheme.wechatSafe && state.meta.font === "sans" ? "适合微信" : "导出 HTML 更稳"}` : ""
1937
+ ] })
1938
+ ] }),
1939
+ state.toast ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-toast", "data-kind": state.toast.kind, children: state.toast.text }) : null
1940
+ ] });
1941
+ }
1942
+ function KeySheet(props) {
1943
+ const { wrapRef, onClose } = props;
1944
+ React.useEffect(() => {
1945
+ const onDown = (event) => {
1946
+ if (!wrapRef.current || !wrapRef.current.contains(event.target)) onClose();
1947
+ };
1948
+ const onKey = (event) => {
1949
+ if (event.key === "Escape") onClose();
1950
+ };
1951
+ document.addEventListener("mousedown", onDown);
1952
+ document.addEventListener("keydown", onKey);
1953
+ return () => {
1954
+ document.removeEventListener("mousedown", onDown);
1955
+ document.removeEventListener("keydown", onKey);
1956
+ };
1957
+ }, [wrapRef, onClose]);
1958
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-keys", role: "dialog", "aria-label": "快捷键", children: [
1959
+ ["编辑", "面板"].map((group) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
1960
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-keys-group", children: group }),
1961
+ SHORTCUTS.filter((s) => s.group === group).map((spec) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-keys-row", children: [
1962
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: spec.label }),
1963
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
1964
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("kbd", { className: "fp-kbd", children: shortcutHint(spec, IS_MAC) })
1965
+ ] }, spec.id))
1966
+ ] }, group)),
1967
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-keys-note", children: [
1968
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
1969
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("kbd", { className: "fp-kbd", children: "Tab" }),
1970
+ " / ",
1971
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("kbd", { className: "fp-kbd", children: IS_MAC ? "⇧Tab" : "Shift+Tab" }),
1972
+ " 缩进、反缩进(列表里正好用)"
1973
+ ] }),
1974
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
1975
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("kbd", { className: "fp-kbd", children: "Enter" }),
1976
+ " 在列表或引用里自动接着写下一项;空条目再按一次就退出"
1977
+ ] }),
1978
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { children: [
1979
+ "截图或图片文件直接 ",
1980
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("kbd", { className: "fp-kbd", children: IS_MAC ? "⌘V" : "Ctrl+V" }),
1981
+ " 粘进来,也可以拖进编辑器"
1982
+ ] })
1983
+ ] })
1984
+ ] });
1985
+ }
1986
+ function Editor(props) {
1987
+ const { editorRef } = props;
1988
+ const [sheet, setSheet] = React.useState(false);
1989
+ const keysRef = React.useRef(null);
1990
+ const closeSheet = React.useCallback(() => setSheet(false), []);
1991
+ const caretOf = (el) => el.value.slice(0, el.selectionStart || 0).split("\n").length;
1992
+ const applyEdit = (edit) => {
1993
+ const el = editorRef.current;
1994
+ if (!el) return;
1995
+ const current = el.value;
1996
+ const slice = current.slice(edit.from, edit.to);
1997
+ if (edit.insert === slice && edit.start === edit.from && edit.end === edit.to) return;
1998
+ el.focus();
1999
+ el.setSelectionRange(edit.from, edit.to);
2000
+ let handled = false;
2001
+ try {
2002
+ handled = document.execCommand("insertText", false, edit.insert);
2003
+ } catch {
2004
+ handled = false;
2005
+ }
2006
+ if (!handled) props.onChange(current.slice(0, edit.from) + edit.insert + current.slice(edit.to));
2007
+ requestAnimationFrame(() => {
2008
+ if (editorRef.current !== el) return;
2009
+ el.setSelectionRange(edit.start, edit.end);
2010
+ props.onCaret(caretOf(el));
2011
+ });
2012
+ };
2013
+ const insertImages = async (files) => {
2014
+ const el = editorRef.current;
2015
+ if (!el) return;
2016
+ let saved = 0;
2017
+ for (const file of files) {
2018
+ try {
2019
+ const { src } = await props.upload(file);
2020
+ const snippet = `![](${src})`;
2021
+ const at = el.selectionStart;
2022
+ applyEdit({ from: at, to: el.selectionEnd, insert: snippet, start: at + snippet.length, end: at + snippet.length });
2023
+ saved += 1;
2024
+ } catch (error) {
2025
+ props.toast(`图片没能存进来:${error.message}`, "error");
2026
+ }
2027
+ }
2028
+ if (saved) props.toast(`已插入 ${saved} 张图片(存在文档同级的 assets/ 里)`);
2029
+ };
2030
+ const onKeyDown = (event) => {
2031
+ const el = event.currentTarget;
2032
+ if (event.key === "Tab") {
2033
+ event.preventDefault();
2034
+ const edit = event.shiftKey ? outdentSelection(el.value, el.selectionStart, el.selectionEnd) : indentSelection(el.value, el.selectionStart, el.selectionEnd);
2035
+ if (edit) applyEdit(edit);
2036
+ return;
2037
+ }
2038
+ if (event.key === "Enter" && !event.shiftKey && !event.altKey && !event.metaKey && !event.ctrlKey) {
2039
+ const edit = continueList(el.value, el.selectionStart, el.selectionEnd);
2040
+ if (edit) {
2041
+ event.preventDefault();
2042
+ applyEdit(edit);
2043
+ }
2044
+ return;
2045
+ }
2046
+ const hit = matchShortcut(event);
2047
+ if (!hit) return;
2048
+ event.preventDefault();
2049
+ if (hit.id === "save") {
2050
+ props.onSave();
2051
+ return;
2052
+ }
2053
+ if (hit.id === "copy") {
2054
+ props.onCopy();
2055
+ return;
2056
+ }
2057
+ applyEdit(applyAction(hit.id, el.value, el.selectionStart, el.selectionEnd));
2058
+ };
2059
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-pane", children: [
2060
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-pane-head", children: [
2061
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: "Markdown 源码" }),
2062
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2063
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-muted", children: props.currentBlock ? `${kindLabel(props.currentBlock.kind)} · 第 ${props.currentBlock.startLine} 行` : "未在块内" }),
2064
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fp-keys-wrap", ref: keysRef, children: [
2065
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2066
+ "button",
2067
+ {
2068
+ type: "button",
2069
+ className: "fp-keys-btn",
2070
+ "data-on": sheet ? "true" : void 0,
2071
+ "aria-expanded": sheet,
2072
+ title: "快捷键:加粗、标题、列表、粘贴图片…",
2073
+ onClick: () => setSheet((open) => !open),
2074
+ children: "快捷键"
2075
+ }
2076
+ ),
2077
+ sheet ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(KeySheet, { wrapRef: keysRef, onClose: closeSheet }) : null
2078
+ ] })
2079
+ ] }),
2080
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2081
+ "textarea",
2082
+ {
2083
+ ref: editorRef,
2084
+ className: "fp-editor",
2085
+ spellCheck: false,
2086
+ value: props.value,
2087
+ onChange: (e) => {
2088
+ props.onChange(e.target.value);
2089
+ props.onCaret(caretOf(e.target));
2090
+ },
2091
+ onClick: (e) => props.onCaret(caretOf(e.currentTarget)),
2092
+ onKeyUp: (e) => props.onCaret(caretOf(e.currentTarget)),
2093
+ onKeyDown,
2094
+ onPaste: (e) => {
2095
+ const files = imageFiles(e.clipboardData);
2096
+ if (!files.length) return;
2097
+ if ((e.clipboardData?.getData("text/plain") || "") !== "") return;
2098
+ e.preventDefault();
2099
+ void insertImages(files);
2100
+ },
2101
+ onDragOver: (e) => {
2102
+ if (e.dataTransfer) e.preventDefault();
2103
+ },
2104
+ onDrop: (e) => {
2105
+ const files = imageFiles(e.dataTransfer);
2106
+ if (!files.length) return;
2107
+ e.preventDefault();
2108
+ void insertImages(files);
2109
+ }
2110
+ }
2111
+ )
2112
+ ] });
2113
+ }
2114
+ function Preview(props) {
2115
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-pane", children: [
2116
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-pane-head", children: [
2117
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: "公众号预览" }),
2118
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2119
+ props.previewing ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: "渲染中…" }) : null,
2120
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-muted", children: props.themeName })
2121
+ ] }),
2122
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-preview-wrap", children: props.html ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2123
+ "iframe",
2124
+ {
2125
+ ref: props.iframeRef,
2126
+ className: "fp-preview",
2127
+ title: "公众号预览",
2128
+ sandbox: "allow-scripts",
2129
+ "data-mobile": props.mobile ? "true" : "false",
2130
+ srcDoc: buildSrcdoc(props.html, props.imageMap, props.hljsCss),
2131
+ onLoad: props.onLoad
2132
+ }
2133
+ ) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-preview-empty", children: "还没有内容" }) })
2134
+ ] });
2135
+ }
2136
+ function Drawer(props) {
2137
+ const open = props.notes.filter((n) => !n.resolved);
2138
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-drawer", children: [
2139
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-drawer-tabs", children: [
2140
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Btn, { on: props.tab === "notes", onClick: () => props.setTab("notes"), children: [
2141
+ "批注 ",
2142
+ open.length || ""
2143
+ ] }),
2144
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Btn, { on: props.tab === "todo", onClick: () => props.setTab("todo"), children: [
2145
+ "待补 ",
2146
+ props.placeholders.length || ""
2147
+ ] }),
2148
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(Btn, { on: props.tab === "blocks", onClick: () => props.setTab("blocks"), children: [
2149
+ "块 ",
2150
+ props.blocks.length
2151
+ ] }),
2152
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { on: props.tab === "history", onClick: () => props.setTab("history"), children: "历史" })
2153
+ ] }),
2154
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-drawer-body", children: [
2155
+ props.tab === "notes" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
2156
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-compose", children: [
2157
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
2158
+ "textarea",
2159
+ {
2160
+ ref: props.noteInputRef,
2161
+ placeholder: "给光标所在段落留一条批注…(模型下次读文档时会看到)",
2162
+ value: props.noteDraft,
2163
+ onChange: (e) => props.setNoteDraft(e.target.value),
2164
+ onKeyDown: (e) => {
2165
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") props.onAddNote();
2166
+ }
2167
+ }
2168
+ ),
2169
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-row", children: [
2170
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "fp-muted", children: [
2171
+ MOD_KEY,
2172
+ " + Enter 添加"
2173
+ ] }),
2174
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2175
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { primary: true, disabled: !props.noteDraft.trim(), onClick: props.onAddNote, children: "添加批注" })
2176
+ ] })
2177
+ ] }),
2178
+ props.notes.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-empty", children: "还没有批注。" }) : null,
2179
+ props.notes.map((n) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item", "data-orphan": n.orphan ? "true" : void 0, children: [
2180
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item-head", children: [
2181
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: n.orphan ? "锚点已失效" : n.blockIndex === null || n.blockIndex === void 0 ? "第 ? 块" : `第 ${n.blockIndex + 1} 块` }),
2182
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: fmtTime(n.at) }),
2183
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2184
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onJumpNote(n), title: "跳到引用它的那段并选中", children: "定位" }),
2185
+ n.orphan ? null : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onResolve(n.id), children: n.resolved ? "已解决" : "标记解决" }),
2186
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onRemove(n.id), children: "删除" })
2187
+ ] }),
2188
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-item-text", children: n.text }),
2189
+ n.quote ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-quote", children: n.quote.slice(0, 120) }) : null
2190
+ ] }, n.id))
2191
+ ] }) : null,
2192
+ props.tab === "todo" ? props.placeholders.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-empty", children: [
2193
+ "没有待补占位。在正文里写 ",
2194
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("code", { children: "<!-- 鱼排: 这里补一句 -->" }),
2195
+ " 即可。"
2196
+ ] }) : props.placeholders.map((p) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item", children: [
2197
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item-head", children: [
2198
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
2199
+ "第 ",
2200
+ p.line,
2201
+ " 行"
2202
+ ] }),
2203
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2204
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onJump(p.line), children: "定位" })
2205
+ ] }),
2206
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-item-text", children: p.text })
2207
+ ] }, `${p.line}-${p.col}`)) : null,
2208
+ props.tab === "blocks" ? props.blocks.map((b) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item", children: [
2209
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item-head", title: `block_id: ${b.id}`, children: [
2210
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
2211
+ "第 ",
2212
+ b.index + 1,
2213
+ " 块"
2214
+ ] }),
2215
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: kindLabel(b.kind) }),
2216
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
2217
+ "第 ",
2218
+ b.startLine,
2219
+ " 行"
2220
+ ] }),
2221
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2222
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onJump(b.startLine), children: "定位" })
2223
+ ] }),
2224
+ b.preview ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-item-text", children: b.preview }) : null
2225
+ ] }, b.id)) : null,
2226
+ props.tab === "history" ? props.history.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-empty", children: "还没有历史版本。" }) : props.history.map((h) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "fp-item", children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "fp-item-head", children: [
2227
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { children: [
2228
+ "rev ",
2229
+ h.rev
2230
+ ] }),
2231
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: h.by === "ai" ? "模型" : h.by === "human" ? "你" : h.by }),
2232
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: fmtTime(h.at) }),
2233
+ h.label ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: h.label }) : null,
2234
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "fp-spacer" }),
2235
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Btn, { onClick: () => props.onRestore(h.id, `rev ${h.rev}${h.label ? `(${h.label})` : ""}`), children: "回滚到这一版" })
2236
+ ] }) }, h.id)) : null
2237
+ ] })
2238
+ ] });
2239
+ }
2240
+
2241
+ // client/styles.ts
2242
+ var STYLE_TAG_ID = "dsh-fishpai/panel.css";
2243
+ var CSS = `
2244
+ .fp-root{
2245
+ display:flex;flex-direction:column;height:100%;min-height:0;
2246
+ color:var(--dsw-alias-label-primary);
2247
+ background:var(--dsw-alias-bg-layer-1);
2248
+ font-size:12px;line-height:1.6;
2249
+ }
2250
+ .fp-root *{box-sizing:border-box}
2251
+ .fp-btn{
2252
+ font:inherit;color:var(--dsw-alias-label-secondary);background:transparent;
2253
+ border:1px solid transparent;border-radius:6px;padding:2px 7px;cursor:pointer;
2254
+ transition:color .15s ease,background-color .15s ease,border-color .15s ease;
2255
+ white-space:nowrap;
2256
+ }
2257
+ .fp-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
2258
+ .fp-btn[data-on="true"]{
2259
+ color:var(--dsw-alias-label-primary);
2260
+ background:var(--dsw-alias-bg-layer-2);
2261
+ border-color:var(--dsw-alias-border-l2);
2262
+ }
2263
+ .fp-btn[disabled]{opacity:.5;cursor:default}
2264
+ /* 置灰的开关不该在悬停时又"亮"起来——那会让人以为还能点 */
2265
+ .fp-btn[disabled]:hover{color:var(--dsw-alias-label-secondary);background:transparent}
2266
+ .fp-btn-primary{
2267
+ color:var(--dsw-alias-label-primary-foreground);
2268
+ background:var(--dsw-alias-button-primary-fill);
2269
+ border-color:transparent;
2270
+ }
2271
+ .fp-btn-primary:hover{color:var(--dsw-alias-label-primary-foreground);filter:brightness(1.06)}
2272
+ .fp-seg{display:inline-flex;gap:1px;padding:1px;border-radius:7px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2)}
2273
+ .fp-seg .fp-btn{border-radius:5px;padding:2px 8px}
2274
+ .fp-seg .fp-btn[data-on="true"]{background:var(--dsw-alias-bg-layer-1);border-color:var(--dsw-alias-border-l1)}
2275
+
2276
+ .fp-toolbar{display:flex;flex-direction:column;gap:4px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l2);flex:none}
2277
+ .fp-row{display:flex;align-items:center;gap:4px;flex-wrap:wrap;min-width:0}
2278
+ .fp-row .fp-spacer{flex:1 1 auto}
2279
+ /* 三个动作按钮是一组:窄到放不下时整体换行,而不是"复制到公众号"被单独挤到下一行 */
2280
+ .fp-actions{display:inline-flex;align-items:center;gap:4px;flex:none;margin-left:auto}
2281
+ /* 面板窄的时候收紧一点:让「导出 HTML / 复制到公众号」仍然留在第一行 */
2282
+ .fp-root[data-narrow="true"] .fp-toolbar{padding:6px 6px;gap:3px}
2283
+ .fp-root[data-narrow="true"] .fp-row{gap:3px}
2284
+ .fp-root[data-narrow="true"] .fp-btn{padding:2px 5px}
2285
+ .fp-root[data-narrow="true"] .fp-actions{gap:3px}
2286
+ .fp-select{
2287
+ font:inherit;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);
2288
+ border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:2px 4px;max-width:11em;
2289
+ }
2290
+
2291
+ /* 主题选择器:自绘 listbox(原生 <option> 放不进图标)。图标全走 currentColor,
2292
+ 所以浅色/深色主题共用一套,不需要换图。 */
2293
+ .fp-picker{position:relative;display:inline-flex}
2294
+ .fp-picker-btn{
2295
+ font:inherit;display:inline-flex;align-items:center;gap:5px;max-width:12em;cursor:pointer;
2296
+ color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2);
2297
+ border:1px solid var(--dsw-alias-border-l2);border-radius:6px;padding:2px 6px;
2298
+ }
2299
+ .fp-picker-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}
2300
+ .fp-picker-glyph{flex:none;color:var(--dsw-alias-label-secondary)}
2301
+ .fp-picker-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2302
+ .fp-picker-caret{flex:none;color:var(--dsw-alias-label-secondary)}
2303
+ .fp-menu{
2304
+ position:absolute;top:calc(100% + 4px);left:0;z-index:40;min-width:12em;
2305
+ max-height:min(60vh,340px);overflow:auto;padding:4px;
2306
+ border:1px solid var(--dsw-alias-border-l2);border-radius:8px;
2307
+ background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-shadow-lv3,0 4px 14px rgba(0,0,0,.28));
2308
+ }
2309
+ /* 分组标题:组名一行、小字提示一行(挤成一行在窄面板里会折得很难看) */
2310
+ .fp-menu-group{
2311
+ position:relative;
2312
+ display:flex;flex-direction:column;gap:1px;
2313
+ padding:6px 6px 3px;font-size:11px;line-height:1.45;color:var(--dsw-alias-label-secondary);
2314
+ }
2315
+ .fp-menu-group-title{font-weight:500}
2316
+ .fp-menu-group-note{font-size:10px;color:var(--dsw-alias-label-secondary);opacity:.8}
2317
+ /* 「我的」那一组右边的删除按钮:只有这一组能删(自定义主题只有一套) */
2318
+ .fp-menu-x{
2319
+ position:absolute;top:4px;right:4px;font:inherit;font-size:12px;line-height:1;
2320
+ padding:2px 5px;cursor:pointer;color:var(--dsw-alias-label-secondary);
2321
+ background:transparent;border:0;border-radius:4px;
2322
+ }
2323
+ .fp-menu-x:hover{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-interactive-bg-hover)}
2324
+ /* 第二组之前来一条细分隔线:一眼能看出"上面是能直接用的,下面是另一类" */
2325
+ .fp-menu-group[data-risk="true"]{
2326
+ margin-top:5px;padding-top:7px;border-top:1px solid var(--dsw-alias-border-l2);
2327
+ }
2328
+ .fp-menu-group[data-risk="true"] .fp-menu-group-note{color:var(--dsw-alias-state-warn-primary);opacity:1}
2329
+ .fp-menu-row{
2330
+ display:flex;align-items:center;gap:6px;width:100%;text-align:left;font:inherit;cursor:pointer;
2331
+ padding:3px 6px;border-radius:5px;border:0;background:transparent;color:var(--dsw-alias-label-primary);
2332
+ }
2333
+ .fp-menu-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover)}
2334
+ .fp-menu-row[data-on="true"]{font-weight:500}
2335
+ .fp-menu-glyph{flex:none;color:var(--dsw-alias-label-secondary)}
2336
+ .fp-menu-name{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2337
+ .fp-menu-tick{flex:none;color:var(--dsw-alias-label-primary)}
2338
+
2339
+ /* tab chip 里的标题:图标 + 文字 */
2340
+ .fp-title-label{display:inline-flex;align-items:center;gap:5px}
2341
+ .fp-title-glyph{flex:none;color:var(--dsw-alias-label-secondary)}
2342
+
2343
+ .fp-swatches{display:flex;gap:3px;align-items:center}
2344
+ .fp-swatch{
2345
+ width:14px;height:14px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;padding:0;
2346
+ }
2347
+ .fp-swatch[data-on="true"]{outline:2px solid var(--dsw-alias-label-primary);outline-offset:1px}
2348
+ .fp-color{width:22px;height:18px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:transparent;cursor:pointer}
2349
+
2350
+ .fp-main{display:flex;flex:1 1 auto;min-height:0;min-width:0}
2351
+ .fp-main[data-layout="stack"]{flex-direction:column}
2352
+ .fp-main[data-layout="side"]{flex-direction:row}
2353
+ .fp-pane{display:flex;flex-direction:column;min-width:0;min-height:0;flex:1 1 50%}
2354
+ .fp-pane + .fp-pane{border-top:1px solid var(--dsw-alias-border-l2)}
2355
+ .fp-main[data-layout="side"] .fp-pane + .fp-pane{border-top:0;border-left:1px solid var(--dsw-alias-border-l2)}
2356
+ .fp-pane-head{
2357
+ display:flex;align-items:center;gap:6px;padding:3px 8px;flex:none;
2358
+ color:var(--dsw-alias-label-secondary);font-size:11px;border-bottom:1px solid var(--dsw-alias-border-l2);
2359
+ /* 速查表浮层挂在这一整行上(不是挂那个小按钮):这样它的百分比宽度有"面板宽度"可依,
2360
+ 窄面板里才会跟着收缩,而不是定宽溢出被裁。 */
2361
+ position:relative;
2362
+ }
2363
+ .fp-editor{
2364
+ flex:1 1 auto;min-height:0;width:100%;resize:none;border:0;outline:none;
2365
+ padding:10px 12px;font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Consolas,monospace);
2366
+ font-size:12.5px;line-height:1.75;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);
2367
+ tab-size:2;
2368
+ }
2369
+ .fp-preview-wrap{flex:1 1 auto;min-height:0;overflow:hidden;background:#fff;display:flex;justify-content:center}
2370
+ .fp-preview{width:100%;height:100%;border:0;background:#fff}
2371
+ .fp-preview[data-mobile="true"]{width:375px;max-width:100%;box-shadow:0 0 0 1px var(--dsw-alias-border-l2)}
2372
+ .fp-preview-empty{color:var(--dsw-alias-label-secondary);padding:16px;text-align:center}
2373
+
2374
+ .fp-drawer{flex:none;border-top:1px solid var(--dsw-alias-border-l2);display:flex;flex-direction:column;max-height:46%;min-height:0}
2375
+ .fp-drawer-tabs{display:flex;gap:2px;padding:3px 6px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2)}
2376
+ .fp-drawer-body{overflow:auto;padding:6px 8px;min-height:0}
2377
+ .fp-empty{color:var(--dsw-alias-label-secondary);padding:8px 2px}
2378
+ .fp-item{
2379
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);
2380
+ border-radius:7px;padding:6px 8px;margin-bottom:6px;
2381
+ }
2382
+ .fp-item[data-orphan="true"]{border-color:var(--dsw-alias-state-warn-primary)}
2383
+ .fp-item-head{display:flex;align-items:baseline;gap:6px;font-size:11px;color:var(--dsw-alias-label-secondary)}
2384
+ .fp-item-head .fp-spacer{flex:1 1 auto}
2385
+ .fp-item-text{margin-top:2px;white-space:pre-wrap;word-break:break-word}
2386
+ .fp-quote{margin-top:3px;font-size:11px;color:var(--dsw-alias-label-secondary);border-left:2px solid var(--dsw-alias-border-l1);padding-left:6px}
2387
+ .fp-compose{display:flex;flex-direction:column;gap:4px;margin-bottom:6px}
2388
+ .fp-compose textarea{
2389
+ width:100%;min-height:48px;resize:vertical;font:inherit;padding:5px 7px;border-radius:6px;
2390
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);
2391
+ }
2392
+ .fp-status{
2393
+ display:flex;align-items:center;gap:8px;padding:3px 8px;flex:none;font-size:11px;
2394
+ color:var(--dsw-alias-label-secondary);border-top:1px solid var(--dsw-alias-border-l2);flex-wrap:wrap;
2395
+ }
2396
+ .fp-banner{
2397
+ margin:6px 8px;padding:6px 8px;border-radius:7px;display:flex;gap:6px;align-items:center;flex-wrap:wrap;
2398
+ border:1px solid var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-bg-layer-2);
2399
+ }
2400
+ .fp-banner[data-kind="error"]{border-color:var(--dsw-alias-state-error-primary)}
2401
+ /* 主题风险提示:与"有未保存改动""冲突"共用同一块区域,各自一行 */
2402
+ .fp-banner[data-kind="warn"]{border-color:var(--dsw-alias-state-warn-primary)}
2403
+ /* 关掉风险提示的 ×:与"快捷键"按钮同一套口径(不抢视觉,但可点区域够大) */
2404
+ .fp-banner-x{
2405
+ flex:none;font:inherit;font-size:13px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:5px;
2406
+ border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary);
2407
+ }
2408
+ .fp-banner-x:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
2409
+ .fp-banner .fp-spacer{flex:1 1 auto}
2410
+ .fp-toast{
2411
+ position:absolute;left:8px;right:8px;bottom:34px;padding:5px 9px;border-radius:7px;font-size:11px;
2412
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);
2413
+ color:var(--dsw-alias-label-primary);box-shadow:var(--dsw-shadow-lv3,0 2px 8px rgba(0,0,0,.18));
2414
+ }
2415
+ /* 失败提示要一眼分得出来:成功与失败的文案不同、停留时间也不同(5s / 2.6s),
2416
+ 再给一层颜色,用户才不会把"复制失败"看成"已复制" */
2417
+ .fp-toast[data-kind="error"]{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}
2418
+
2419
+ /* 没有文档时的空白页:一张居中的卡片。空面板只有一行小字飘在中间,既看不出是什么、
2420
+ 也看不出下一步能做什么——所以三种状态(载入中 / 载入失败 / 还没有文档)共用这张卡。 */
2421
+ .fp-blank{flex:1 1 auto;min-height:0;overflow:auto;display:flex;align-items:center;justify-content:center;padding:16px}
2422
+ .fp-blank-card{
2423
+ width:100%;max-width:400px;display:flex;flex-direction:column;gap:10px;
2424
+ padding:14px 16px;border-radius:10px;
2425
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);
2426
+ }
2427
+ .fp-blank-mark{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dsw-alias-label-primary)}
2428
+ /* 品牌标:鱼形图标放在一块小方瓦里(图标本身用 currentColor,深浅色通吃) */
2429
+ .fp-blank-glyph{
2430
+ width:26px;height:26px;border-radius:8px;display:flex;align-items:center;justify-content:center;
2431
+ background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);
2432
+ color:var(--dsw-alias-label-primary);
2433
+ }
2434
+ .fp-blank-line{font-size:12px;line-height:1.7;color:var(--dsw-alias-label-primary)}
2435
+ .fp-blank-line code{
2436
+ font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Consolas,monospace);
2437
+ background:var(--dsw-alias-bg-layer-1);border-radius:4px;padding:0 4px;
2438
+ }
2439
+ .fp-blank-actions{display:flex;gap:6px;align-items:center}
2440
+ .fp-loading-bar{
2441
+ height:2px;border-radius:2px;overflow:hidden;background:var(--dsw-alias-bg-layer-1);position:relative;
2442
+ }
2443
+ .fp-loading-bar::after{
2444
+ content:"";position:absolute;top:0;bottom:0;width:40%;border-radius:2px;
2445
+ background:var(--dsw-alias-label-secondary);opacity:.5;
2446
+ animation:fp-sweep 1.2s ease-in-out infinite;
2447
+ }
2448
+ @keyframes fp-sweep{
2449
+ 0%{left:-40%}
2450
+ 100%{left:100%}
2451
+ }
2452
+ .fp-doclist{display:flex;flex-direction:column;gap:1px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:8px}
2453
+ .fp-doclist-head{font-size:11px;color:var(--dsw-alias-label-secondary);padding:0 6px 2px}
2454
+ .fp-docrow{
2455
+ display:flex;align-items:baseline;gap:8px;width:100%;text-align:left;font:inherit;cursor:pointer;
2456
+ padding:4px 6px;border-radius:6px;border:1px solid transparent;background:transparent;
2457
+ color:var(--dsw-alias-label-primary);
2458
+ }
2459
+ .fp-docrow:hover{background:var(--dsw-alias-interactive-bg-hover);border-color:var(--dsw-alias-border-l2)}
2460
+ .fp-docrow-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2461
+ .fp-docrow .fp-spacer{flex:1 1 auto}
2462
+ .fp-root{position:relative}
2463
+ .fp-muted{color:var(--dsw-alias-label-secondary)}
2464
+ .fp-warn{color:var(--dsw-alias-state-warn-primary)}
2465
+
2466
+ /* 快捷键速查表:挂在编辑器头上的小按钮 + 一块列表(内容和键盘处理同源) */
2467
+ /* 注意是 static:浮层的定位上下文交给 .fp-pane-head(整行),见那里的注释。
2468
+ (这里是模板字符串里,注释别用反引号——会把字符串闭合掉。) */
2469
+ .fp-keys-wrap{display:inline-flex;flex:none}
2470
+ .fp-keys-btn{
2471
+ font:inherit;font-size:11px;line-height:1;cursor:pointer;padding:3px 6px;border-radius:5px;
2472
+ border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-secondary);
2473
+ }
2474
+ .fp-keys-btn:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover)}
2475
+ .fp-keys-btn[data-on="true"]{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2)}
2476
+ .fp-keys{
2477
+ position:absolute;top:calc(100% + 4px);right:0;z-index:3;
2478
+ /* 宽度**随面板收缩**:以前写死 266px,窄面板里比面板还宽、向左溢出被裁掉,
2479
+ 表现就是"快捷键标签全被切掉、只剩右边一排键帽"。 */
2480
+ width:auto;min-width:min(232px,100%);max-width:calc(100% - 12px);
2481
+ max-height:60vh;overflow:auto;
2482
+ padding:6px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);
2483
+ background:var(--dsw-alias-bg-layer-1);box-shadow:0 8px 24px rgba(0,0,0,.18);
2484
+ color:var(--dsw-alias-label-primary);font-size:11.5px;line-height:1.9;text-align:left;
2485
+ }
2486
+ .fp-keys-group{padding:2px 4px;color:var(--dsw-alias-label-secondary);font-size:11px}
2487
+ .fp-keys-row{display:flex;align-items:baseline;gap:8px;padding:0 4px;min-width:0}
2488
+ /* 标签列要能被压窄(中文按字换行),键帽保持一行不折 */
2489
+ .fp-keys-row > span:first-child{flex:0 1 auto;min-width:0}
2490
+ .fp-keys-row .fp-spacer{flex:1 1 auto;min-width:0}
2491
+ .fp-keys-note{
2492
+ margin-top:4px;padding:5px 4px 1px;border-top:1px solid var(--dsw-alias-border-l2);
2493
+ color:var(--dsw-alias-label-secondary);line-height:1.75;
2494
+ }
2495
+ .fp-kbd{
2496
+ flex:none;font-family:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Consolas,monospace);
2497
+ font-size:10.5px;padding:1px 5px;border-radius:4px;white-space:nowrap;
2498
+ border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);
2499
+ }
2500
+ `;
2501
+ function ensureStyles(doc = document) {
2502
+ const selector = `style[data-plugin-css=${JSON.stringify(STYLE_TAG_ID)}]`;
2503
+ if (doc.querySelector(selector)) return () => {
2504
+ };
2505
+ const tag = doc.createElement("style");
2506
+ tag.dataset.plugin = "dsh-fishpai";
2507
+ tag.dataset.pluginCss = STYLE_TAG_ID;
2508
+ tag.textContent = CSS;
2509
+ doc.head.appendChild(tag);
2510
+ return () => tag.remove();
2511
+ }
2512
+
2513
+ // client/index.tsx
2514
+ var name = "dsh-fishpai";
2515
+ var inject = ["slots", "sessions"];
2516
+ var TAB_ID = "dsh-fishpai";
2517
+ var TAB_KIND = "fishpai";
2518
+ var FALLBACK_TAB_ID = `${TAB_ID}:editor`;
2519
+ var POLL_MS = 3e3;
2520
+ function pick(injected, key) {
2521
+ if (!injected) return void 0;
2522
+ try {
2523
+ if (typeof injected.get === "function") {
2524
+ const value = injected.get(key);
2525
+ if (value !== void 0) return value;
2526
+ }
2527
+ } catch {
2528
+ }
2529
+ return injected[key];
2530
+ }
2531
+ function service(ctx, key) {
2532
+ try {
2533
+ return typeof ctx.get === "function" ? ctx.get(key) : void 0;
2534
+ } catch {
2535
+ return void 0;
2536
+ }
2537
+ }
2538
+ function currentSessionId(ctx, readMounted) {
2539
+ try {
2540
+ const sessions = service(ctx, "sessions");
2541
+ const snapshot = sessions?.list?.getSnapshot?.();
2542
+ if (snapshot?.current) return snapshot.current;
2543
+ const mounted = readMounted?.() || null;
2544
+ if (mounted) return mounted;
2545
+ if (Array.isArray(snapshot?.ids) && snapshot.ids.length === 1) return snapshot.ids[0];
2546
+ return null;
2547
+ } catch {
2548
+ return null;
2549
+ }
2550
+ }
2551
+ function apply(ctx) {
2552
+ const stores = /* @__PURE__ */ new Map();
2553
+ const disposeStyles = ensureStyles();
2554
+ ctx.effect(() => () => disposeStyles(), "fishpai: 面板样式");
2555
+ let getMountedSession = null;
2556
+ const readMounted = () => {
2557
+ try {
2558
+ return getMountedSession ? getMountedSession() : null;
2559
+ } catch {
2560
+ return null;
2561
+ }
2562
+ };
2563
+ const storeFor = (sessionId) => {
2564
+ let store = stores.get(sessionId);
2565
+ if (!store) {
2566
+ store = createFishpaiStore(sessionId);
2567
+ stores.set(sessionId, store);
2568
+ }
2569
+ return store;
2570
+ };
2571
+ function PanelHost(props) {
2572
+ const sessionId = props?.sessionId || currentSessionId(ctx, readMounted);
2573
+ const store = React2.useMemo(() => sessionId ? storeFor(sessionId) : null, [sessionId]);
2574
+ if (!sessionId || !store) {
2575
+ return React2.createElement("div", { className: "fp-root" }, "鱼排:拿不到当前会话,请在一个会话里打开。");
2576
+ }
2577
+ return React2.createElement(Panel, { store, sessionId });
2578
+ }
2579
+ function Title() {
2580
+ return React2.createElement(
2581
+ "span",
2582
+ { className: "fp-title-label" },
2583
+ React2.createElement(FishGlyph, { size: 14, className: "fp-title-glyph" }),
2584
+ "鱼排编辑器"
2585
+ );
2586
+ }
2587
+ let openOfficial = null;
2588
+ let openFallback = null;
2589
+ const openPanel = () => {
2590
+ const open = openOfficial || openFallback;
2591
+ if (!open) throw new Error("右侧栏通道尚未就绪");
2592
+ open();
2593
+ };
2594
+ const openIfRequested = (sessionId, docKey) => {
2595
+ try {
2596
+ openPanel?.();
2597
+ } catch (error) {
2598
+ console.warn("[dsh-fishpai] 打开面板失败,下一次轮询会重试:", error);
2599
+ return false;
2600
+ }
2601
+ storeFor(sessionId);
2602
+ void api.tabOpened(sessionId, docKey).catch(() => {
2603
+ });
2604
+ return true;
2605
+ };
2606
+ let officialReady = false;
2607
+ let disposeFallbackTab = null;
2608
+ let seatHandle = null;
2609
+ try {
2610
+ seatHandle = ctx.inject(["sidebarRightTabs", "sidebarRight"], (injected) => {
2611
+ const tabs = pick(injected, "sidebarRightTabs");
2612
+ const sidebarRight = pick(injected, "sidebarRight");
2613
+ getMountedSession = () => {
2614
+ try {
2615
+ return sidebarRight?.mounted?.getSnapshot?.() || null;
2616
+ } catch {
2617
+ return null;
2618
+ }
2619
+ };
2620
+ if (!tabs || typeof tabs.register !== "function") return;
2621
+ const disposers = [];
2622
+ disposers.push(
2623
+ tabs.register({
2624
+ id: TAB_ID,
2625
+ kind: TAB_KIND,
2626
+ priority: "extension",
2627
+ title: () => "鱼排编辑器",
2628
+ guide: [
2629
+ {
2630
+ // 0.1.7 起 guide 条目契约要求稳定 id(注册时的唯一性检查与渲染 key 都按它走);
2631
+ // 旧版注册处不读这个字段,多了无害。
2632
+ id: "editor",
2633
+ order: 45,
2634
+ title: () => "鱼排编辑器",
2635
+ description: () => "公众号排版:Markdown + 实时预览 + 与模型来回改稿",
2636
+ // 不给 icon 的话,新标签页的引导列表画一个默认的立方体占位(就是"看着像缺图标"的那个)
2637
+ icon: FishGlyph
2638
+ }
2639
+ ]
2640
+ })
2641
+ );
2642
+ const slots = ctx.slots;
2643
+ if (slots && typeof slots.inject === "function") {
2644
+ disposers.push(
2645
+ slots.inject(
2646
+ "sidebar.right.pane.tab",
2647
+ () => slots.register({ name: "sidebar.right.pane.tab", key: TAB_ID }, PanelHost)
2648
+ )
2649
+ );
2650
+ disposers.push(
2651
+ slots.inject(
2652
+ "sidebar.right.pane.tab.title",
2653
+ () => slots.register({ name: "sidebar.right.pane.tab.title", key: TAB_ID }, Title)
2654
+ )
2655
+ );
2656
+ }
2657
+ if (sidebarRight && typeof sidebarRight.openTab === "function") {
2658
+ openOfficial = () => sidebarRight.openTab(TAB_KIND);
2659
+ }
2660
+ officialReady = true;
2661
+ if (disposeFallbackTab) {
2662
+ try {
2663
+ disposeFallbackTab();
2664
+ } catch {
2665
+ }
2666
+ disposeFallbackTab = null;
2667
+ openFallback = null;
2668
+ }
2669
+ const dispose = () => {
2670
+ for (const off of disposers.reverse()) {
2671
+ try {
2672
+ off();
2673
+ } catch {
2674
+ }
2675
+ }
2676
+ officialReady = false;
2677
+ openOfficial = null;
2678
+ getMountedSession = null;
2679
+ };
2680
+ return dispose;
2681
+ });
2682
+ } catch (error) {
2683
+ console.warn("[dsh-fishpai] 官方右侧栏席位注入失败:", error);
2684
+ }
2685
+ let fallbackHandle = null;
2686
+ try {
2687
+ fallbackHandle = ctx.inject(["betterSidebar"], (injected) => {
2688
+ if (officialReady) return;
2689
+ const bs = pick(injected, "betterSidebar");
2690
+ if (!bs || typeof bs.registerTab !== "function") return;
2691
+ const off = bs.registerTab({
2692
+ id: FALLBACK_TAB_ID,
2693
+ title: () => "鱼排编辑器",
2694
+ // better-sidebar 的 TabDescriptor 支持 icon(它把它转交给原生右侧栏的引导页)
2695
+ icon: (size) => React2.createElement(FishGlyph, { size: size || 16 }),
2696
+ order: 40,
2697
+ single: true,
2698
+ component: (props) => React2.createElement(PanelHost, props)
2699
+ });
2700
+ openFallback = () => bs.openTab({ type: FALLBACK_TAB_ID });
2701
+ disposeFallbackTab = () => {
2702
+ try {
2703
+ off();
2704
+ } catch {
2705
+ }
2706
+ };
2707
+ return off;
2708
+ });
2709
+ } catch (error) {
2710
+ console.warn("[dsh-fishpai] better-sidebar 回退注入失败:", error);
2711
+ }
2712
+ ctx.effect(() => {
2713
+ let stopped = false;
2714
+ let warned = false;
2715
+ let lastRevision = -1;
2716
+ let lastKey = null;
2717
+ let lastTheme = "";
2718
+ let lastSession = "";
2719
+ const seenSessions = /* @__PURE__ */ new Set();
2720
+ const tick = async () => {
2721
+ if (stopped || typeof document !== "undefined" && document.visibilityState === "hidden") return;
2722
+ const sessionId = currentSessionId(ctx, readMounted);
2723
+ if (!sessionId) return;
2724
+ try {
2725
+ const st = await api.state(sessionId);
2726
+ warned = false;
2727
+ if (sessionId !== lastSession) {
2728
+ lastSession = sessionId;
2729
+ lastKey = null;
2730
+ lastRevision = -1;
2731
+ lastTheme = "";
2732
+ }
2733
+ if (st.openRequest && st.openRequest.key) {
2734
+ openIfRequested(sessionId, st.openRequest.key);
2735
+ }
2736
+ const activeKey = st.active ? st.active.key : null;
2737
+ const revision = st.active ? st.active.revision : -1;
2738
+ const themeKey = st.active ? st.active.theme : "";
2739
+ const firstPoll = !seenSessions.has(sessionId);
2740
+ seenSessions.add(sessionId);
2741
+ const switched = activeKey !== lastKey || revision !== lastRevision || themeKey !== lastTheme;
2742
+ lastKey = activeKey;
2743
+ lastRevision = revision;
2744
+ lastTheme = themeKey;
2745
+ const store = stores.get(sessionId);
2746
+ if (store && switched && !firstPoll && activeKey) void store.actions.onActiveDoc(activeKey, revision, themeKey);
2747
+ } catch (error) {
2748
+ if (!warned) {
2749
+ warned = true;
2750
+ console.warn("[dsh-fishpai] 轮询失败(宿主路由可能尚未就绪):", error);
2751
+ }
2752
+ }
2753
+ };
2754
+ const timer = setInterval(() => void tick(), POLL_MS);
2755
+ void tick();
2756
+ return () => {
2757
+ stopped = true;
2758
+ clearInterval(timer);
2759
+ seatHandle?.dispose?.();
2760
+ fallbackHandle?.dispose?.();
2761
+ disposeFallbackTab?.();
2762
+ for (const store of stores.values()) store.dispose();
2763
+ stores.clear();
2764
+ };
2765
+ }, "fishpai: 轮询与生命周期");
2766
+ }
2767
+
2768
+ return module.exports;
2769
+ },
2770
+ });