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/codemirror.js +29 -29
- package/dist/index.js +124 -18
- package/dist/panel.html +252 -15
- package/i18n/zh-CN.json +2 -2
- package/package.json +7 -3
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
|
|
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"
|
|
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': '
|
|
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': '
|
|
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': '
|
|
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': '
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
2248
|
-
|
|
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": "
|
|
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": "
|
|
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.
|
|
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": "
|
|
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": "
|
|
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": "",
|