finch-markdown-editor 0.1.0 → 0.1.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/dist/panel.html CHANGED
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
- <title>Markdown Editor</title>
6
+ <title>写作</title>
7
7
  <style>
8
8
  :root{
9
9
  --bg:var(--finch-bg-root,#f7f7f8);
@@ -70,6 +70,10 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
70
70
  .doc-sub{margin-top:2px;display:flex;gap:6px;font-size:11px;line-height:1.4;color:var(--muted)}
71
71
  .doc-sub .doc-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
72
72
  .doc-sub .doc-time{flex:none;opacity:.8}
73
+ .doc-reveal{position:absolute;top:6px;right:6px;z-index:1;display:flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:7px;background:color-mix(in srgb, var(--bg) 72%, transparent);color:var(--muted);cursor:pointer;opacity:0;transition:opacity .12s ease,background .12s ease,color .12s ease}
74
+ .doc-reveal svg{width:14px;height:14px}
75
+ .doc-card:hover .doc-reveal,.doc-reveal:focus-visible{opacity:1}
76
+ .doc-reveal:hover{background:var(--card);color:var(--text)}
73
77
  .home-hint{margin:0;padding:22px 0;text-align:center;font-size:12.5px;color:var(--muted)}
74
78
  .home-hint[hidden],.home-recent[hidden]{display:none}
75
79
  .status{font-size:12px;color:var(--muted);padding:5px 10px;text-align:center;border-top:1px solid var(--border);background:var(--bg)}
@@ -141,7 +145,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
141
145
  <div class="home">
142
146
  <header class="home-head">
143
147
  <div>
144
- <h1 data-i18n="home.title">Markdown Editor</h1>
148
+ <h1 data-i18n="home.title">写作</h1>
145
149
  <p id="homeTagline"></p>
146
150
  </div>
147
151
  <div class="home-cta">
@@ -230,7 +234,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
230
234
  var STR = {
231
235
  zh: {
232
236
  'meta.title': '公众号文章预览',
233
- 'home.title': 'Markdown 编辑',
237
+ 'home.title': '写作',
234
238
  'home.tagline.withAssistant': '你与{name}一起编排 Markdown 文件',
235
239
  'home.tagline.default': '打开一个 Markdown 文件开始编辑与预览,或新建一篇文章。',
236
240
  'home.openFile': '打开文件',
@@ -239,11 +243,12 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
239
243
  'home.hintScoped': '这个目录里还没有用编辑器打开过的 Markdown 文档。',
240
244
  'home.hintDefault': '打开一个 Markdown 文件,之后这里会列出最近编辑的文档。',
241
245
  'home.untitled': '未命名',
246
+ 'home.revealInFileManager': '在文件管理器中定位',
242
247
  'home.emptyDoc': '(空文档)',
243
248
  'home.cwdTitle': '在访达 / 文件资源管理器中打开:{path}',
244
249
  'home.today': '今天 {clock}',
245
250
  'home.yesterday': '昨天 {clock}',
246
- 'editor.ariaLabel': 'Markdown 编辑器',
251
+ 'editor.ariaLabel': '写作编辑器',
247
252
  'actions.copyToWx': '复制到公众号',
248
253
  'actions.copyImg': '复制图片',
249
254
  'actions.exportImg': '导出图片(下载)',
@@ -283,7 +288,12 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
283
288
  'toolbar.fontSize.large': '大',
284
289
  'toolbar.fontFamily.default': '默认(无衬线)',
285
290
  'toolbar.fontFamily.serif': '衬线体',
291
+ 'toolbar.comfort.read': '紧凑',
292
+ 'toolbar.comfort.write': '舒适',
293
+ 'toolbar.focus.tooltipOn': '专注已开启:非当前行半透明',
294
+ 'toolbar.focus.tooltipOff': '开启专注:非当前行半透明',
286
295
  'toolbar.more.reload': '重新渲染',
296
+ 'toolbar.more.reveal': '在文件管理器中定位',
287
297
  'toolbar.more.about': '关于渲染',
288
298
  'annotate.lineUnknown': '(未能精确定位对应行号,请以下方引用文本为准)',
289
299
  'annotate.lineSingle': '第 {a} 行',
@@ -314,6 +324,9 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
314
324
  'status.noSourceFile': '未关联本地文件:点击工具栏「打开」重新选择一次即可关联并启用保存。',
315
325
  'status.pickerTimeout': '文件选择器长时间无响应,已切换为浏览器内选择器:请重新点击"打开"。',
316
326
  'status.externalUpdateSkipped': '检测到文件外部更新;为避免覆盖未保存内容,暂未载入。',
327
+ 'status.draftRestored': '已恢复上次未保存的编辑内容(与磁盘文件不同)。如需放弃,点击「打开」重新选择该文件并选择"放弃"。',
328
+ 'status.draftConflict': '检测到本地存有未保存草稿,但文件在别处已被修改保存;已加载最新文件内容,草稿仍保留在本地未丢弃。',
329
+ 'status.sourceMissing': '原文件已被移动或删除;当前内容仍保留在编辑器中,保存将在原路径重新创建文件。',
317
330
  'status.externalUpdateApplied': '检测到文件更新,已刷新。',
318
331
  'status.openingPicker': '正在打开文件选择器…',
319
332
  'status.opening': '正在打开…',
@@ -340,6 +353,10 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
340
353
  'status.exported': '已导出:{path}',
341
354
  'status.annotateOn': '批注已开启:选中文字可发起 AI 修改。',
342
355
  'status.annotateOff': '批注已关闭。',
356
+ 'status.comfortWrite': '已切换为舒适模式(行距加宽)。',
357
+ 'status.comfortRead': '已切换为紧凑模式。',
358
+ 'status.focusOn': '专注已开启:非当前行半透明。',
359
+ 'status.focusOff': '专注已关闭。',
343
360
  'status.saveFailedNoReturn': '保存失败,未返回首页。',
344
361
  'status.rendererAbout': '渲染器:本机 bmmd CLI(bm.md,AGPL-3.0)。',
345
362
  'status.appliedStyleSlot': '已应用「{label}」。',
@@ -355,7 +372,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
355
372
  },
356
373
  en: {
357
374
  'meta.title': 'Article Preview',
358
- 'home.title': 'Markdown Editor',
375
+ 'home.title': 'Writing',
359
376
  'home.tagline.withAssistant': 'Write Markdown together with {name}',
360
377
  'home.tagline.default': 'Open a Markdown file to start editing and previewing, or create a new article.',
361
378
  'home.openFile': 'Open File',
@@ -364,11 +381,12 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
364
381
  'home.hintScoped': 'No Markdown documents have been opened from this folder yet.',
365
382
  'home.hintDefault': 'Open a Markdown file — recently edited documents will show up here.',
366
383
  'home.untitled': 'Untitled',
384
+ 'home.revealInFileManager': 'Show in file manager',
367
385
  'home.emptyDoc': '(Empty document)',
368
386
  'home.cwdTitle': 'Open in Finder / File Explorer: {path}',
369
387
  'home.today': 'Today {clock}',
370
388
  'home.yesterday': 'Yesterday {clock}',
371
- 'editor.ariaLabel': 'Markdown editor',
389
+ 'editor.ariaLabel': 'Writing editor',
372
390
  'actions.copyToWx': 'Copy for WeChat',
373
391
  'actions.copyImg': 'Copy image',
374
392
  'actions.exportImg': 'Export image (download)',
@@ -408,7 +426,12 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
408
426
  'toolbar.fontSize.large': 'Large',
409
427
  'toolbar.fontFamily.default': 'Default (sans-serif)',
410
428
  'toolbar.fontFamily.serif': 'Serif',
429
+ 'toolbar.comfort.read': 'Compact',
430
+ 'toolbar.comfort.write': 'Comfortable',
431
+ 'toolbar.focus.tooltipOn': 'Focus mode is on — inactive lines are dimmed',
432
+ 'toolbar.focus.tooltipOff': 'Turn on focus mode: dim inactive lines',
411
433
  'toolbar.more.reload': 'Re-render',
434
+ 'toolbar.more.reveal': 'Show in file manager',
412
435
  'toolbar.more.about': 'About rendering',
413
436
  'annotate.lineUnknown': "(Couldn't pinpoint the exact line — use the quoted text below as reference)",
414
437
  'annotate.lineSingle': 'Line {a}',
@@ -439,6 +462,9 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
439
462
  'status.noSourceFile': 'Not linked to a local file: click “Open” in the toolbar again to link it and enable saving.',
440
463
  'status.pickerTimeout': 'The file picker took too long to respond and switched to the in-browser picker — please click "Open" again.',
441
464
  'status.externalUpdateSkipped': 'Detected an external file update; not loaded yet to avoid overwriting unsaved changes.',
465
+ 'status.draftRestored': 'Restored your last unsaved edits (differs from the file on disk). To discard, click "Open" and pick this file again, then choose "Discard".',
466
+ 'status.draftConflict': 'A local unsaved draft exists, but the file was changed and saved elsewhere; loaded the latest file content — the draft is kept, not discarded.',
467
+ 'status.sourceMissing': 'The original file was moved or deleted; your content is still here — saving will recreate the file at its original path.',
442
468
  'status.externalUpdateApplied': 'Detected a file update and refreshed.',
443
469
  'status.openingPicker': 'Opening the file picker…',
444
470
  'status.opening': 'Opening…',
@@ -465,6 +491,10 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
465
491
  'status.exported': 'Exported: {path}',
466
492
  'status.annotateOn': 'Annotate is on — select text to ask AI to revise it.',
467
493
  'status.annotateOff': 'Annotate is off.',
494
+ 'status.comfortWrite': 'Comfortable mode is on — roomier line spacing.',
495
+ 'status.comfortRead': 'Compact mode is on.',
496
+ 'status.focusOn': 'Focus mode is on — inactive lines are dimmed.',
497
+ 'status.focusOff': 'Focus mode is off.',
468
498
  'status.saveFailedNoReturn': 'Save failed — staying on this document.',
469
499
  'status.rendererAbout': 'Renderer: local bmmd CLI (bm.md, AGPL-3.0).',
470
500
  'status.appliedStyleSlot': 'Applied \u201c{label}\u201d.',
@@ -553,6 +583,11 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
553
583
  };
554
584
  var editorFontSize = 14;
555
585
  var editorFont = 'rounded';
586
+ // Comfortable mode: roomier line spacing while composing. Default off
587
+ // (compact mode keeps the tighter 2px/1.7rem layout).
588
+ var comfortWriting = false;
589
+ // Focus mode ("专注"): dims every line except the cursor's own line.
590
+ var focusMode = false;
556
591
  try {
557
592
  // Only the three menu tiers are valid; anything else (missing, corrupt,
558
593
  // or from an older tier set) falls back to the default 14px tier.
@@ -561,6 +596,8 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
561
596
  var savedEditorFont = localStorage.getItem('md-editor-font-family');
562
597
  if (EDITOR_FONTS[savedEditorFont]) editorFont = savedEditorFont;
563
598
  else if (savedEditorFont) localStorage.removeItem('md-editor-font-family');
599
+ comfortWriting = localStorage.getItem('md-editor-comfort-writing') === '1';
600
+ focusMode = localStorage.getItem('md-editor-focus-mode') === '1';
564
601
  } catch (e) {}
565
602
  var markdown = '';
566
603
  var name = '';
@@ -732,6 +769,9 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
732
769
  for (var i = 0; i < list.length; i++) {
733
770
  var doc = list[i] || {};
734
771
  cards += '<button class="doc-card" type="button" data-path="' + escapeHtml(doc.path || '') + '" title="' + escapeHtml(doc.path || '') + '">'
772
+ + '<span class="doc-reveal" data-reveal-path="' + escapeHtml(doc.path || '') + '" role="button" tabindex="0" title="' + escapeHtml(t('home.revealInFileManager')) + '" aria-label="' + escapeHtml(t('home.revealInFileManager')) + '">'
773
+ + '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></svg>'
774
+ + '</span>'
735
775
  + '<div class="doc-preview">' + escapeHtml(doc.preview || t('home.emptyDoc')) + '</div>'
736
776
  + '<div class="doc-foot">'
737
777
  + '<div class="doc-title">' + escapeHtml(doc.title || doc.fileName || t('home.untitled')) + '</div>'
@@ -751,14 +791,35 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
751
791
  var loadingFromHome = false;
752
792
 
753
793
  if (homeGrid) homeGrid.addEventListener('click', function (event) {
794
+ if (!api || !api.postMessage) return;
795
+ var reveal = event.target && event.target.closest ? event.target.closest('.doc-reveal') : null;
796
+ if (reveal) {
797
+ event.preventDefault();
798
+ event.stopPropagation();
799
+ var revealPath = reveal.getAttribute('data-reveal-path') || '';
800
+ if (revealPath) api.postMessage({ type: 'openPath', path: revealPath });
801
+ return;
802
+ }
754
803
  var card = event.target && event.target.closest ? event.target.closest('.doc-card') : null;
755
- if (!card || !api || !api.postMessage) return;
804
+ if (!card) return;
756
805
  var docPath = card.getAttribute('data-path') || '';
757
806
  if (!docPath) return;
758
807
  loadingFromHome = true;
759
808
  setStatus(t('status.opening'));
760
809
  api.postMessage({ type: 'loadPath', path: docPath });
761
810
  });
811
+ // The reveal control is keyboard-focusable (role="button" tabindex="0")
812
+ // since it's nested inside the card's own <button>; Enter/Space need their
813
+ // own handler because a non-<button> element doesn't get native activation.
814
+ if (homeGrid) homeGrid.addEventListener('keydown', function (event) {
815
+ if (event.key !== 'Enter' && event.key !== ' ') return;
816
+ var reveal = event.target && event.target.closest ? event.target.closest('.doc-reveal') : null;
817
+ if (!reveal || !api || !api.postMessage) return;
818
+ event.preventDefault();
819
+ event.stopPropagation();
820
+ var revealPath = reveal.getAttribute('data-reveal-path') || '';
821
+ if (revealPath) api.postMessage({ type: 'openPath', path: revealPath });
822
+ });
762
823
 
763
824
  function updateEmptyState() {
764
825
  empty.hidden = hasDocument();
@@ -867,11 +928,22 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
867
928
  // the user first clicked somewhere in the page. Those actions now sit
868
929
  // in the in-page floating bar over the preview (see `.actions` above),
869
930
  // where a click is a real user gesture and they succeed on first try.
931
+ {
932
+ // Focus mode ("专注"): dims every line except the cursor's own, a
933
+ // checked toggle like annotate. Editing-only; preview has no lines.
934
+ // Icon-only like `open` — the tooltip explains what it does.
935
+ id: 'focus', icon: 'ext:markdown-editor-icons/feather',
936
+ tooltip: hasDoc ? (focusMode ? t('toolbar.focus.tooltipOn') : t('toolbar.focus.tooltipOff')) : t('toolbar.needDoc'),
937
+ checked: focusMode, disabled: !hasDoc || mode === 'preview',
938
+ },
870
939
  {
871
940
  type: 'menu', id: 'font-size', icon: 'ext:markdown-editor-icons/type',
872
941
  tooltip: !hasDoc ? t('toolbar.needDoc') : mode === 'preview' ? t('toolbar.fontSize.tooltipPreview') : t('toolbar.fontSize.tooltipEdit'),
873
942
  disabled: !hasDoc || mode === 'preview',
874
943
  items: [
944
+ { id: 'comfort:read', label: t('toolbar.comfort.read'), checked: !comfortWriting },
945
+ { id: 'comfort:write', label: t('toolbar.comfort.write'), checked: comfortWriting },
946
+ { id: 'font-comfort-sep', label: '', separator: true },
875
947
  { id: 'font-size:14', label: t('toolbar.fontSize.small'), checked: editorFontSize === 14 },
876
948
  { id: 'font-size:16', label: t('toolbar.fontSize.medium'), checked: editorFontSize === 16 },
877
949
  { id: 'font-size:18', label: t('toolbar.fontSize.large'), checked: editorFontSize === 18 },
@@ -884,6 +956,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
884
956
  type: 'menu', id: 'more', icon: 'settings',
885
957
  items: [
886
958
  { id: 'reload', label: t('toolbar.more.reload'), disabled: mode !== 'preview' },
959
+ { id: 'reveal', label: t('toolbar.more.reveal'), disabled: !hasDoc },
887
960
  { id: 'more-sep', label: '', separator: true },
888
961
  { id: 'about', label: t('toolbar.more.about') },
889
962
  ],
@@ -938,6 +1011,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
938
1011
  closePopup();
939
1012
  updateEmptyState();
940
1013
  setDirty(true);
1014
+ scheduleDraftSave();
941
1015
  },
942
1016
  onOpenLink: function (url) {
943
1017
  if (api && api.postMessage) api.postMessage({ type: 'openLink', url: url });
@@ -960,6 +1034,8 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
960
1034
  });
961
1035
  cm.setFontSize(editorFontSize);
962
1036
  cm.setFontFamily(EDITOR_FONTS[editorFont]);
1037
+ cm.setComfortWriting(comfortWriting);
1038
+ cm.setFocusMode(focusMode);
963
1039
  cm.scrollDOM.addEventListener('scroll', closePopup);
964
1040
 
965
1041
  // ---- Markdown <-> bm.md rendering ----
@@ -1274,6 +1350,22 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1274
1350
  async function submitAnnotation() {
1275
1351
  var comment = popupInput.value.trim();
1276
1352
  if (!selection.text || !api || !api.composer) return;
1353
+ // The prompt tells the AI it can read/overwrite the file at `sourcePath`
1354
+ // directly — but if there are unsaved edits, disk still has the old
1355
+ // content: the AI would read stale context (or, if it applies a
1356
+ // full-document rewrite based on that stale read, would clobber the
1357
+ // very edits the user hasn't saved yet). Flush to disk first whenever
1358
+ // there's somewhere to save to, so the file the AI reads always matches
1359
+ // what's on screen. A brand new unsaved document (no sourcePath) has
1360
+ // nowhere to save to yet — the prompt already says so via
1361
+ // `annotate.pathMissing` — so there's nothing to flush in that case.
1362
+ if (dirty && sourcePath) {
1363
+ var saved = await saveNowAndWait();
1364
+ if (!saved) {
1365
+ setStatus(t('status.saveFailedNoReturn'), true);
1366
+ return;
1367
+ }
1368
+ }
1277
1369
  try {
1278
1370
  await api.composer.addContexts([{
1279
1371
  type: 'annotation',
@@ -1321,6 +1413,59 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1321
1413
  var dirty = false;
1322
1414
  var savedFlash = false; // true for ~3s right after a successful save, drives the save-check icon
1323
1415
 
1416
+ // ---- Unsaved-draft recovery: mirror edits to a backend sidecar file so a
1417
+ // crash, an accidental "Open" over unsaved work, or just closing without
1418
+ // saving never actually loses anything — reopening the same path resumes
1419
+ // from here instead of the last save.
1420
+ //
1421
+ // This used to debounce the send itself (~1.2s after typing stopped) and
1422
+ // then try to force-flush on blur/pagehide/visibilitychange when the tab
1423
+ // looked like it might be closing. That was unreliable in practice: a
1424
+ // Cmd/Ctrl+W on an already-focused panel tears it down directly without
1425
+ // reliably running page-lifecycle JS first, so the "flush on the way out"
1426
+ // half of that plan couldn't be counted on — losing whatever was typed in
1427
+ // the last debounce window.
1428
+ //
1429
+ // So instead: every keystroke posts immediately, with no client-side
1430
+ // delay at all — by the time this call returns, the latest content has
1431
+ // already left the panel. The *disk write* is debounced on the backend
1432
+ // instead (see `scheduleDraftWrite` in index.ts), where the timer lives
1433
+ // in the long-running extension host rather than in this page, so it
1434
+ // keeps ticking (and still writes) even if the panel is destroyed a
1435
+ // moment later. The host also flushes immediately on panel disposal, so
1436
+ // real teardown never has to wait out any debounce either.
1437
+ var draftPathSaved = ''; // path the last-sent draft belongs to, so a document switch can't cross-write it
1438
+ var draftLastSent = null; // content of the most recent saveDraft actually posted, to skip redundant re-sends
1439
+
1440
+ function postDraftSave(path) {
1441
+ draftPathSaved = path;
1442
+ draftLastSent = markdown;
1443
+ // `base` = the last disk-confirmed content, so the backend can record
1444
+ // a baseline and detect if the file changes elsewhere before we reopen.
1445
+ api.postMessage({ type: 'saveDraft', path: path, markdown: markdown, base: savedMarkdown });
1446
+ }
1447
+
1448
+ function scheduleDraftSave() {
1449
+ if (!sourcePath || !api || !api.postMessage) return;
1450
+ if (draftPathSaved === sourcePath && draftLastSent === markdown) return; // nothing actually changed
1451
+ postDraftSave(sourcePath);
1452
+ }
1453
+
1454
+ // No-op now that there's no client-side timer to cancel — kept as a named
1455
+ // call so the "we're done editing this document" call sites (save
1456
+ // success, reset to home, document switch) still read as intentional and
1457
+ // reset the dedupe state below them, without every call site needing to
1458
+ // know the send is already synchronous.
1459
+ function cancelDraftSave() {
1460
+ draftPathSaved = '';
1461
+ draftLastSent = null;
1462
+ }
1463
+
1464
+ function discardDraftFor(path) {
1465
+ cancelDraftSave();
1466
+ if (path && api && api.postMessage) api.postMessage({ type: 'discardDraft', path: path });
1467
+ }
1468
+
1324
1469
  function setDirty(next, justSaved) {
1325
1470
  dirty = next;
1326
1471
  if (savedFlashTimer) { clearTimeout(savedFlashTimer); savedFlashTimer = 0; }
@@ -1344,6 +1489,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1344
1489
  savedMarkdown = content;
1345
1490
  setDirty(markdown !== content, markdown === content);
1346
1491
  setStatus(markdown === content ? t('status.saved') : t('status.savedWithNewChanges'));
1492
+ cancelDraftSave();
1347
1493
  return true;
1348
1494
  } catch (e) {
1349
1495
  setStatus(t('status.saveFailed', { err: String(e) }), true);
@@ -1385,8 +1531,13 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1385
1531
  fileHandle = null;
1386
1532
  }
1387
1533
 
1388
- function applyDocument(nextMarkdown, nextName, nextPath, force) {
1389
- if (!force && dirty && cm.hasFocus() && savedMarkdown !== null && nextMarkdown !== savedMarkdown) {
1534
+ function applyDocument(nextMarkdown, nextName, nextPath, force, diskBaseline) {
1535
+ // Deliberately does NOT check cm.hasFocus(): unsaved local edits must
1536
+ // never be silently clobbered by an external update, whether or not the
1537
+ // editor happens to have DOM focus at that instant (e.g. the panel was
1538
+ // blurred mid-edit). The user's own uncommitted work always wins here;
1539
+ // the (still-running) draft mirror already backs it up on disk either way.
1540
+ if (!force && dirty && savedMarkdown !== null && nextMarkdown !== savedMarkdown) {
1390
1541
  setStatus(t('status.externalUpdateSkipped'), true);
1391
1542
  return false;
1392
1543
  }
@@ -1394,7 +1545,15 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1394
1545
  name = nextName;
1395
1546
  if (nextPath) sourcePath = nextPath;
1396
1547
  fileName = pathFileName(nextPath || sourcePath) || nextName || 'Markdown';
1397
- savedMarkdown = markdown;
1548
+ // `savedMarkdown` must track the file's real *on-disk* content, not
1549
+ // necessarily what's being displayed — when a restored draft differs
1550
+ // from disk (`diskBaseline` provided and != nextMarkdown), keeping
1551
+ // `savedMarkdown` at the draft's own text would poison the next draft
1552
+ // mirror's baseline hash: it'd be recorded against a draft that was
1553
+ // never actually on disk, so a later reopen would see the *true* disk
1554
+ // content sitting unchanged at the real baseline and wrongly report
1555
+ // an external-edit conflict that never happened.
1556
+ savedMarkdown = typeof diskBaseline === 'string' ? diskBaseline : markdown;
1398
1557
  cm.setValue(markdown);
1399
1558
  setDirty(false); // also rebuilds+syncs the whole toolbar, picking up the new sourcePath/fileName below
1400
1559
  setPanelTitle(fileName);
@@ -1467,7 +1626,21 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
1467
1626
  }, 20000);
1468
1627
  }
1469
1628
 
1629
+ // Picking a *different* file to open would otherwise silently replace
1630
+ // whatever's currently in the buffer — this is the same "unsaved work"
1631
+ // hazard as the Home button, just reached from a different toolbar item,
1632
+ // so it gets the same confirm-first treatment.
1470
1633
  async function openFile() {
1634
+ if (dirty) {
1635
+ var choice = await askUnsavedConfirm(t('confirm.message', { file: fileName || t('common.markdownDocDefault') }));
1636
+ if (choice === 'cancel') return;
1637
+ if (choice === 'save') {
1638
+ var saved = await saveNowAndWait();
1639
+ if (!saved) { setStatus(t('status.saveFailedNoReturn'), true); return; }
1640
+ } else if (choice === 'discard') {
1641
+ discardDraftFor(sourcePath);
1642
+ }
1643
+ }
1471
1644
  stopWatching();
1472
1645
  if (pickFileSupported && api && api.postMessage) {
1473
1646
  if (nativePickPending) {
@@ -2019,9 +2192,42 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
2019
2192
  syncToolbar();
2020
2193
  }
2021
2194
 
2195
+ function setComfortWriting(on) {
2196
+ comfortWriting = !!on;
2197
+ cm.setComfortWriting(comfortWriting);
2198
+ try { localStorage.setItem('md-editor-comfort-writing', comfortWriting ? '1' : '0'); } catch (e) {}
2199
+ syncToolbar();
2200
+ setStatus(comfortWriting ? t('status.comfortWrite') : t('status.comfortRead'));
2201
+ }
2202
+
2203
+ function toggleFocusMode() {
2204
+ focusMode = !focusMode;
2205
+ // Mirror of the annotate guard in toggleAnnotations: annotate dims
2206
+ // nothing but needs the whole doc readable to pick passages, while
2207
+ // focus deliberately dims everything else — the two modes are
2208
+ // mutually exclusive, so turning focus on forces annotate off.
2209
+ if (focusMode && annotationsEnabled) {
2210
+ annotationsEnabled = false;
2211
+ closePopup();
2212
+ }
2213
+ cm.setFocusMode(focusMode);
2214
+ try { localStorage.setItem('md-editor-focus-mode', focusMode ? '1' : '0'); } catch (e) {}
2215
+ syncToolbar();
2216
+ setStatus(focusMode ? t('status.focusOn') : t('status.focusOff'));
2217
+ }
2218
+
2022
2219
  function toggleAnnotations() {
2023
2220
  annotationsEnabled = !annotationsEnabled;
2024
2221
  if (!annotationsEnabled) closePopup();
2222
+ // Annotate and focus mode fight each other: focus dims every non-active
2223
+ // line, which makes it hard to spot the text you're about to annotate
2224
+ // elsewhere in the doc. Turning annotate on forces focus off so the
2225
+ // whole document reads normally while selecting passages.
2226
+ if (annotationsEnabled && focusMode) {
2227
+ focusMode = false;
2228
+ cm.setFocusMode(false);
2229
+ try { localStorage.setItem('md-editor-focus-mode', '0'); } catch (e) {}
2230
+ }
2025
2231
  syncToolbar();
2026
2232
  setStatus(annotationsEnabled ? t('status.annotateOn') : t('status.annotateOff'));
2027
2233
  }
@@ -2062,6 +2268,7 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
2062
2268
  // tells the backend to stop watching it, then shows the launch page again.
2063
2269
  function resetToHome() {
2064
2270
  stopWatching();
2271
+ cancelDraftSave();
2065
2272
  closePopup();
2066
2273
  markdown = '';
2067
2274
  name = '';
@@ -2099,8 +2306,11 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
2099
2306
  if (choice === 'save') {
2100
2307
  var saved = await saveNowAndWait();
2101
2308
  if (!saved) { setStatus(t('status.saveFailedNoReturn'), true); return; }
2309
+ } else if (choice === 'discard') {
2310
+ // The user explicitly threw these edits away — don't let the draft
2311
+ // ghost-reappear next time this file is opened.
2312
+ discardDraftFor(sourcePath);
2102
2313
  }
2103
- // choice === 'discard' falls through and leaves without saving.
2104
2314
  }
2105
2315
  resetToHome();
2106
2316
  }
@@ -2113,8 +2323,12 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
2113
2323
  if (itemId === 'mode') { setMode(mode === 'edit' ? 'preview' : 'edit'); return; }
2114
2324
  if (itemId === 'save') { saveNow(); return; }
2115
2325
  if (itemId === 'reload') { render(); return; }
2326
+ if (itemId === 'reveal') { if (sourcePath && api && api.postMessage) api.postMessage({ type: 'openPath', path: sourcePath }); return; }
2116
2327
  if (itemId && itemId.indexOf('font-size:') === 0) { setEditorFontSize(Number(itemId.slice('font-size:'.length))); return; }
2117
2328
  if (itemId && itemId.indexOf('font-family:') === 0) { setEditorFont(itemId.slice('font-family:'.length)); return; }
2329
+ if (itemId === 'comfort:read') { setComfortWriting(false); return; }
2330
+ if (itemId === 'comfort:write') { setComfortWriting(true); return; }
2331
+ if (itemId === 'focus') { toggleFocusMode(); return; }
2118
2332
  if (itemId === 'about') { setStatus(t('status.rendererAbout')); return; }
2119
2333
  // These two must be checked before the generic 'style:' fallback below,
2120
2334
  // since both also start with the literal prefix "style:".
@@ -2242,10 +2456,33 @@ body{margin:0;background:var(--bg);color:var(--text);font:13px var(--finch-font-
2242
2456
  var isFirst = savedMarkdown === null;
2243
2457
  var wasLoadingFromHome = loadingFromHome;
2244
2458
  loadingFromHome = false;
2245
- var applied = applyDocument(incoming, m.title || t('common.markdownDocDefault'), m.path || sourcePath, openedFromPicker || isFirst || isDifferentDocument);
2246
- if (!applied) syncToolbar(); // still clear the "open" button's pending/disabled state
2247
- if (applied && wasLoadingFromHome) setStatus(t('status.opened', { file: fileName }));
2248
- else if (applied && !isFirst) setStatus(t('status.contentUpdated'));
2459
+ var applied = applyDocument(incoming, m.title || t('common.markdownDocDefault'), m.path || sourcePath, openedFromPicker || isFirst || isDifferentDocument, m.diskMarkdown);
2460
+ if (!applied) { syncToolbar(); return; } // still clear the "open" button's pending/disabled state
2461
+ if (m.draftRestored) {
2462
+ // The buffer we just loaded is an unsaved draft, not what's on
2463
+ // disk — keep it marked dirty so Save (or the draft-mirror timer)
2464
+ // stays active, and say so briefly instead of pretending nothing
2465
+ // happened. No blocking prompt: it's just the user's own last edit.
2466
+ setDirty(true);
2467
+ setStatus(t('status.draftRestored'));
2468
+ } else if (m.draftConflict) {
2469
+ // A draft existed, but the file was saved elsewhere (another
2470
+ // editor, an AI apply) after that draft was taken — loading the
2471
+ // draft over that would silently discard someone else's confirmed
2472
+ // save, and loading disk over the draft without saying anything
2473
+ // would silently discard the user's own edit. Load disk (the only
2474
+ // version anyone else can see) and say so plainly instead of
2475
+ // guessing; the stale draft file itself is left alone on disk.
2476
+ setStatus(t('status.draftConflict'), true);
2477
+ } else if (wasLoadingFromHome) {
2478
+ setStatus(t('status.opened', { file: fileName }));
2479
+ } else if (!isFirst) {
2480
+ setStatus(t('status.contentUpdated'));
2481
+ }
2482
+ return;
2483
+ }
2484
+ if (m.type === 'sourceMissing') {
2485
+ if (m.path && m.path === sourcePath) setStatus(t('status.sourceMissing'), true);
2249
2486
  return;
2250
2487
  }
2251
2488
  if (m.type === 'bmRendered') {
package/i18n/zh-CN.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
- "name": "Markdown 编辑",
2
+ "name": "写作",
3
3
  "description": "在 Finch 中编辑 Markdown,并可预览为微信公众号文章样式;支持框选文字请求 AI 修改和自定义排版。",
4
4
  "systemPrompt": "当用户要编辑或预览本地 Markdown 文件时,调用 markdown_editor_document,action 使用 open,并提供绝对路径。当用户要新建/起草一篇全新的 Markdown 文档时,自己先写好完整 Markdown 正文,再调用 action=create 并提供一个尚不存在的绝对路径和 markdown 内容——会一次性写入文件并在 Markdown 编辑器中打开(编辑器界面本身故意不提供“新建”按钮)。当用户要修改编辑器或预览中框选的文字时,给出精确的替换 Markdown,确认后调用 action=apply 并提供完整更新后的 Markdown。当用户要求自定义/AI 设计公众号排版风格时,编写限定在 #bm-md 下的普通 CSS(标签/id 选择器,不用 class,必要时用 !important 覆盖基础风格),可参考 bm.md 内置风格(kami、bauhaus、blueprint、botanical、newsprint、retro、sketch、terminal),然后调用 action=set_style 并提供 css 与简短 label,会立即应用到当前打开的预览。",
5
5
  "appPanel": {
6
- "title": "Markdown 编辑",
6
+ "title": "写作",
7
7
  "toolbar": {
8
8
  "open": { "tooltip": "打开 Markdown 文件" },
9
9
  "annotate": { "label": "批注", "tooltip": "开启批注" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "finch-markdown-editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A Markdown editor and WeChat Official Account style preview, with selection-based AI edits and AI-generated custom styles.",
5
5
  "author": {
6
6
  "name": "PuterJam",
@@ -34,7 +34,7 @@
34
34
  "finch": {
35
35
  "manifestVersion": 1,
36
36
  "minVersion": "1.6.0",
37
- "name": "MD Editor",
37
+ "name": "写作",
38
38
  "description": "Edit Markdown and preview it as a WeChat Official Account article. Select text for AI edits, or ask AI to design a custom style.",
39
39
  "systemPrompt": "When the user wants to edit or preview a local Markdown file, call markdown_editor_document with action 'open' and its absolute path. When the user asks to create/draft a brand-new Markdown document, write the full Markdown yourself and call action 'create' with an absolute path that does not exist yet plus the markdown content — this writes the file and opens it in Markdown Editor in one step (Markdown Editor's own UI intentionally has no 'new file' button). When the user asks to revise a passage selected in Markdown Editor, propose precise replacement Markdown and, once approved, call action 'apply' with the full updated Markdown. When the user asks for a custom/AI-designed WeChat layout style, write plain CSS scoped under #bm-md (tag/id selectors only, use !important where needed to override the base style) inspired by bm.md's built-in styles (kami, bauhaus, blueprint, botanical, newsprint, retro, sketch, terminal), then call action 'set_style' with that css and a short label — it applies immediately in the open preview.",
40
40
  "main": "dist/index.js",
@@ -50,7 +50,7 @@
50
50
  "iconPacks": [
51
51
  {
52
52
  "id": "markdown-editor-icons",
53
- "label": "Markdown Editor Icons"
53
+ "label": "写作图标"
54
54
  }
55
55
  ],
56
56
  "appPanel": {
@@ -156,6 +156,10 @@
156
156
  "id": "reload",
157
157
  "label": "Re-render"
158
158
  },
159
+ {
160
+ "id": "reveal",
161
+ "label": "Show in file manager"
162
+ },
159
163
  {
160
164
  "id": "more-sep",
161
165
  "label": "",