dsh-tiddlywiki 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -134,6 +134,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
134
134
  display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
135
135
  font-family: inherit;
136
136
  }
137
+ /* The wrapper's display:flex would beat the UA [hidden]{display:none} rule,
138
+ so the card's show/hide (root.hidden) needs an explicit rule. */
139
+ .dsh-tw-note[hidden] { display: none; }
137
140
  .dsh-tw-note-card {
138
141
  width: 340px; max-width: calc(100vw - 40px);
139
142
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
@@ -374,6 +377,113 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
374
377
  .dsh-tw-settings-error { color: var(--dsw-alias-state-error-primary, #d13b3b); font-size: 12px; }
375
378
  .dsh-tw-settings-search { flex: 0 0 auto; max-width: 220px; }
376
379
  .dsh-tw-settings-check { accent-color: var(--dsw-alias-brand-primary, #3e63dd); }
380
+
381
+ /* ── "知识库" FAB (v0.5: quick-note + sync + panel status merged) ──────────
382
+ One fixed cluster above the shutdown launcher FAB (bottom:24). The FAB
383
+ carries a git status dot; the menu pops upward from it. */
384
+ .dsh-tw-fab-wrap {
385
+ position: fixed; right: 24px; bottom: 88px; z-index: 960;
386
+ display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
387
+ }
388
+ .dsh-tw-fab {
389
+ position: relative;
390
+ width: 46px; height: 46px; border-radius: 50%;
391
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
392
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
393
+ box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
394
+ display: inline-flex; align-items: center; justify-content: center;
395
+ transition: background-color 120ms ease, transform 80ms ease;
396
+ }
397
+ .dsh-tw-fab:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
398
+ .dsh-tw-fab:active { transform: scale(.94); }
399
+ .dsh-tw-fab-icon { display: inline-flex; }
400
+ .dsh-tw-fab-dot {
401
+ position: absolute; right: 0; bottom: 0; width: 11px; height: 11px;
402
+ border-radius: 50%; border: 2px solid var(--dsw-alias-bg-layer-2, #fff);
403
+ background: #999; box-sizing: border-box;
404
+ }
405
+ .dsh-tw-fab-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
406
+ .dsh-tw-fab-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
407
+ .dsh-tw-fab-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
408
+ .dsh-tw-fab-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
409
+ .dsh-tw-fab-menu {
410
+ min-width: 220px; max-width: calc(100vw - 48px);
411
+ display: flex; flex-direction: column; gap: 2px; padding: 6px;
412
+ border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.15));
413
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
414
+ box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
415
+ font-size: 13px;
416
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
417
+ animation: dsh-tw-note-in 160ms ease;
418
+ }
419
+ .dsh-tw-fab-menu[hidden] { display: none; }
420
+ .dsh-tw-fab-status {
421
+ display: flex; align-items: center; gap: 7px;
422
+ padding: 6px 9px; font-size: 12px; color: var(--dsw-alias-label-secondary, #666);
423
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08));
424
+ margin-bottom: 4px;
425
+ }
426
+ .dsh-tw-fab-status + .dsh-tw-fab-status { border-bottom: 0; margin-bottom: 4px; }
427
+ .dsh-tw-fab-status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
428
+ .dsh-tw-fab-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
429
+ .dsh-tw-fab-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
430
+ .dsh-tw-fab-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
431
+ .dsh-tw-fab-status-dot[data-state="failed"], .dsh-tw-fab-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
432
+ .dsh-tw-fab-status-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
433
+ .dsh-tw-fab-status-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
434
+ .dsh-tw-fab-status-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
435
+ .dsh-tw-fab-status-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
436
+ .dsh-tw-fab-item {
437
+ display: flex; align-items: center; gap: 6px; text-align: left;
438
+ border: none; background: transparent; color: inherit; font: inherit; font-size: 13px;
439
+ padding: 7px 9px; border-radius: 8px; cursor: pointer;
440
+ }
441
+ .dsh-tw-fab-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
442
+ .dsh-tw-fab-item:active { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); }
443
+
444
+ /* ── quick-note restored-draft banner ─────────────────────────────────────── */
445
+ .dsh-tw-note-draft {
446
+ display: flex; align-items: center; justify-content: space-between; gap: 8px;
447
+ padding: 5px 9px; border-radius: 8px; font-size: 12px;
448
+ background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #d9822b) 12%, transparent);
449
+ color: var(--dsw-alias-label-primary, #222);
450
+ }
451
+ .dsh-tw-note-draft[hidden] { display: none; }
452
+ .dsh-tw-note-draft-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
453
+ .dsh-tw-note-draft-discard {
454
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
455
+ background: transparent; color: inherit; font: inherit; font-size: 12px;
456
+ padding: 2px 10px; border-radius: 999px; cursor: pointer; flex: none;
457
+ }
458
+ .dsh-tw-note-draft-discard:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
459
+
460
+ /* ── quick-note recent-notes picker ───────────────────────────────────────── */
461
+ .dsh-tw-note-recent {
462
+ position: absolute; right: 0; bottom: calc(100% - 8px); z-index: 70;
463
+ width: 340px; max-width: calc(100vw - 40px); max-height: 280px; overflow-y: auto;
464
+ display: flex; flex-direction: column; gap: 2px; padding: 6px;
465
+ border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.15));
466
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
467
+ box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
468
+ font-size: 13px;
469
+ }
470
+ .dsh-tw-note-recent[hidden] { display: none; }
471
+ .dsh-tw-note-recent-item {
472
+ display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
473
+ padding: 6px 8px; border-radius: 7px; cursor: pointer;
474
+ }
475
+ .dsh-tw-note-recent-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
476
+ .dsh-tw-note-recent-name { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
477
+ .dsh-tw-note-recent-meta { flex: none; font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
478
+ .dsh-tw-note-recent-muted { padding: 8px 10px; font-size: 12px; color: var(--dsw-alias-label-dimmed, #999); }
479
+ .dsh-tw-note-recent-btn {
480
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
481
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
482
+ font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
483
+ transition: filter 120ms ease, transform 80ms ease;
484
+ }
485
+ .dsh-tw-note-recent-btn:hover { filter: brightness(.96); }
486
+ .dsh-tw-note-recent-btn:active { transform: scale(.96); }
377
487
  `;
378
488
  function injectStyles() {
379
489
  if (typeof document === "undefined") return;
@@ -570,7 +680,7 @@ const PANEL_HOST_SELECTOR = "[data-dsh-panel-host]";
570
680
  const APP_OVERLAY_Z_INDEX = 20;
571
681
  /** Safety re-measure cadence for shell layout changes CSS can't see. */
572
682
  const SYNC_INTERVAL_MS = 2e3;
573
- const STATUS_ENDPOINT$2 = "/dsh-tiddlywiki/status";
683
+ const STATUS_ENDPOINT$3 = "/dsh-tiddlywiki/status";
574
684
  const RESTART_ENDPOINT$1 = "/dsh-tiddlywiki/restart";
575
685
  /**
576
686
  * The panel's z-index: below any dsh-better-sidebar host layer so its
@@ -595,7 +705,7 @@ function conversationColumn() {
595
705
  }
596
706
  async function fetchStatus$1() {
597
707
  try {
598
- const res = await fetch(STATUS_ENDPOINT$2, { signal: AbortSignal.timeout(8e3) });
708
+ const res = await fetch(STATUS_ENDPOINT$3, { signal: AbortSignal.timeout(8e3) });
599
709
  if (!res.ok) return null;
600
710
  return await res.json();
601
711
  } catch {
@@ -618,9 +728,6 @@ function mountPanel(state) {
618
728
  let iframe;
619
729
  let frameArea;
620
730
  let errorArea;
621
- let chip;
622
- let statusDot;
623
- let statusFloater;
624
731
  let refreshTimer;
625
732
  let refreshAttempts = 0;
626
733
  const build = () => {
@@ -641,45 +748,6 @@ function mountPanel(state) {
641
748
  view.append(frameArea, errorArea);
642
749
  return view;
643
750
  };
644
- /**
645
- * Bottom-right floating status/reload buttons (same visual language as the
646
- * quick-note toggle) — the top panel bar is gone, so TW's menubar reaches
647
- * the very top of the panel and clears the host's top-right buttons. Mount
648
- * is gated by `ui.showPanelStatus` (settings page toggle).
649
- */
650
- const buildStatusFloater = () => {
651
- const wrap = document.createElement("div");
652
- wrap.className = "dsh-tw-panel-status";
653
- const statusBtn = document.createElement("button");
654
- statusBtn.type = "button";
655
- statusBtn.className = "dsh-tw-panel-status-btn";
656
- statusBtn.title = "刷新状态";
657
- statusDot = document.createElement("span");
658
- statusDot.className = "dsh-tw-status-dot";
659
- statusDot.dataset.state = "unknown";
660
- chip = document.createElement("span");
661
- chip.className = "dsh-tw-panel-status-text";
662
- chip.textContent = "—";
663
- statusBtn.append(statusDot, chip);
664
- statusBtn.addEventListener("click", () => {
665
- doRefresh();
666
- });
667
- const reloadBtn = document.createElement("button");
668
- reloadBtn.type = "button";
669
- reloadBtn.className = "dsh-tw-panel-status-btn";
670
- reloadBtn.textContent = "重载";
671
- reloadBtn.title = "重载 TiddlyWiki 面板";
672
- reloadBtn.addEventListener("click", () => {
673
- if (iframe !== void 0 && !iframe.hidden) iframe.src = iframe.src;
674
- });
675
- wrap.append(statusBtn, reloadBtn);
676
- document.body.append(wrap);
677
- return wrap;
678
- };
679
- const setChip = (stateName, text) => {
680
- if (statusDot !== void 0) statusDot.dataset.state = stateName;
681
- if (chip !== void 0) chip.textContent = text;
682
- };
683
751
  /** Pin the overlay to the center column's current viewport rect. */
684
752
  const syncRect = () => {
685
753
  if (container === void 0 || columnEl === void 0) return;
@@ -762,18 +830,15 @@ function mountPanel(state) {
762
830
  }
763
831
  const payload = await fetchStatus$1();
764
832
  if (payload === null) {
765
- setChip("failed", "状态不可达");
766
833
  showError("无法访问 /dsh-tiddlywiki/status");
767
834
  return;
768
835
  }
769
836
  if (payload.status === "running" && typeof payload.url === "string") {
770
- setChip("running", "在线");
771
837
  refreshAttempts = 0;
772
838
  showFrame(payload.url);
773
839
  return;
774
840
  }
775
841
  if (payload.status === "starting") {
776
- setChip("starting", "启动中");
777
842
  showStarting();
778
843
  if (refreshAttempts < 30) {
779
844
  refreshAttempts++;
@@ -783,7 +848,6 @@ function mountPanel(state) {
783
848
  }
784
849
  return;
785
850
  }
786
- setChip("failed", "离线");
787
851
  refreshAttempts = 0;
788
852
  showError(payload.error ?? `服务状态:${payload.status}`);
789
853
  };
@@ -832,10 +896,10 @@ function mountPanel(state) {
832
896
  const unsubscribe = state.subscribe(applyActive);
833
897
  ensure();
834
898
  applyActive();
835
- (async () => {
836
- if ((await fetchStatus$1())?.ui?.showPanelStatus === false) return;
837
- statusFloater = buildStatusFloater();
838
- })();
899
+ const onReloadRequest = () => {
900
+ if (iframe !== void 0 && !iframe.hidden) iframe.src = iframe.src;
901
+ };
902
+ document.addEventListener("dsh-tw-panel-reload", onReloadRequest);
839
903
  return () => {
840
904
  if (refreshTimer !== void 0) window.clearTimeout(refreshTimer);
841
905
  window.clearInterval(syncInterval);
@@ -844,11 +908,11 @@ function mountPanel(state) {
844
908
  resizeObserver.disconnect();
845
909
  document.removeEventListener("click", onClickSidebarRow, true);
846
910
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate);
911
+ document.removeEventListener("dsh-tw-panel-reload", onReloadRequest);
847
912
  waitObserver.disconnect();
848
913
  unsubscribe();
849
914
  document.documentElement.removeAttribute(ACTIVE_ATTR);
850
915
  container?.remove();
851
- statusFloater?.remove();
852
916
  };
853
917
  }
854
918
  //#endregion
@@ -26408,7 +26472,10 @@ function buildMarkdownEditor(opts = {}) {
26408
26472
  return true;
26409
26473
  }
26410
26474
  }
26411
- ])
26475
+ ]),
26476
+ EditorView.updateListener.of((update) => {
26477
+ if (update.docChanged) opts.onChange?.();
26478
+ })
26412
26479
  ]
26413
26480
  })
26414
26481
  });
@@ -26442,39 +26509,66 @@ function buildMarkdownEditor(opts = {}) {
26442
26509
  //#endregion
26443
26510
  //#region src/client/note-widget.ts
26444
26511
  /**
26445
- * Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
26446
- * collapsible, independent of any shell DOM. Lets the human jot drafts /
26447
- * scratch notes while waiting for the AI or drafting the next prompt.
26512
+ * Floating quick-note card (design doc §12, D6/D7) — a bottom-right, fixed,
26513
+ * collapsible card for jotting drafts / scratch notes while waiting for the AI
26514
+ * or drafting the next prompt. In v0.5 the trigger moved into the "知识库" FAB
26515
+ * (knowledge-fab.ts); this module ONLY owns the card itself via
26516
+ * `createNoteWidget()` (lazy build — no DOM until first open).
26517
+ *
26518
+ * Features:
26519
+ * - CodeMirror 6 Markdown editor (see markdown-editor.ts), file upload + drag
26520
+ * & drop, multi-tag chips with autocomplete (GET /dsh-tiddlywiki/tags).
26521
+ * - DRAFT PERSISTENCE: editor/title/tags auto-save to localStorage on a 500ms
26522
+ * debounce; reopening restores the draft with a "丢弃" banner. A successful
26523
+ * save / "在 TW 中编辑" clears it.
26524
+ * - RECENT PICKER: a 「最近」 button lists the newest notes (GET
26525
+ * /dsh-tiddlywiki/recent); clicking one loads it into the editor (GET
26526
+ * /dsh-tiddlywiki/get) to continue editing.
26527
+ * - Ctrl+Enter saves; 「✏️ 在 TW 中编辑」 saves and opens TW's native editor.
26448
26528
  *
26449
26529
  * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
26450
26530
  * editable; defaults: timestamp title + config tag, usually "inbox").
26451
26531
  *
26452
- * ui.showQuickNote config: the widget is mounted async and stays hidden (and
26453
- * never appended) when the option is off, so the toggle button can be disabled
26454
- * from the settings page without touching the shell.
26455
- *
26456
- * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
26457
- * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
26458
- * an empty field removes the last chip; the dropdown filters on typing.
26459
- *
26460
- * Markdown editing: a CodeMirror 6 editor (see markdown-editor.ts) with real
26461
- * Lezer-based syntax highlighting (GFM: headings, lists, code, links, tables,
26462
- * task lists, strikethrough…) plus undo/redo, wired into the same
26463
- * `.dsh-tw-note-editor` surface the widget reads/writes.
26464
- *
26465
- * File upload: the body accepts files via the 📎 button or drag-and-drop;
26466
- * each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
26467
- * `files/` folder, git-tracked and served at `/files/<name>`) and a Markdown
26468
- * image/link line is inserted at the caret.
26469
- *
26470
26532
  * @module dsh-tiddlywiki/client/note-widget
26471
26533
  */
26472
26534
  const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
26473
26535
  const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
26474
- const STATUS_ENDPOINT$1 = "/dsh-tiddlywiki/status";
26536
+ const STATUS_ENDPOINT$2 = "/dsh-tiddlywiki/status";
26475
26537
  const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
26538
+ const RECENT_ENDPOINT = "/dsh-tiddlywiki/recent";
26539
+ const GET_ENDPOINT = "/dsh-tiddlywiki/get";
26476
26540
  const UPLOAD_ENDPOINT = "/dsh-tiddlywiki/upload";
26477
26541
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
26542
+ /** localStorage key for the autosaved quick-note draft. */
26543
+ const DRAFT_KEY = "dsh-tw-note-draft-v1";
26544
+ /** Draft auto-save debounce. */
26545
+ const DRAFT_DEBOUNCE_MS = 500;
26546
+ function loadDraft() {
26547
+ try {
26548
+ const raw = localStorage.getItem(DRAFT_KEY);
26549
+ if (raw === null) return null;
26550
+ const parsed = JSON.parse(raw);
26551
+ if (typeof parsed.text !== "string" || typeof parsed.title !== "string") return null;
26552
+ return {
26553
+ text: parsed.text,
26554
+ title: parsed.title,
26555
+ tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t) => typeof t === "string") : [],
26556
+ savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0
26557
+ };
26558
+ } catch {
26559
+ return null;
26560
+ }
26561
+ }
26562
+ function persistDraft(draft) {
26563
+ try {
26564
+ localStorage.setItem(DRAFT_KEY, JSON.stringify(draft));
26565
+ } catch {}
26566
+ }
26567
+ function clearDraft() {
26568
+ try {
26569
+ localStorage.removeItem(DRAFT_KEY);
26570
+ } catch {}
26571
+ }
26478
26572
  function pad$1(n) {
26479
26573
  return n < 10 ? `0${n}` : String(n);
26480
26574
  }
@@ -26482,13 +26576,28 @@ function pad$1(n) {
26482
26576
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
26483
26577
  return `${date.getFullYear()}-${pad$1(date.getMonth() + 1)}-${pad$1(date.getDate())} ${pad$1(date.getHours())}:${pad$1(date.getMinutes())}`;
26484
26578
  }
26579
+ /** Compact Chinese relative time for the recent picker (e.g. "3小时前"). */
26580
+ function relativeTime(iso) {
26581
+ if (iso === null) return "";
26582
+ const ms = new Date(iso).getTime();
26583
+ if (Number.isNaN(ms)) return "";
26584
+ const diff = Date.now() - ms;
26585
+ const min = Math.floor(diff / 6e4);
26586
+ if (min < 1) return "刚刚";
26587
+ if (min < 60) return `${min}分钟前`;
26588
+ const hr = Math.floor(min / 60);
26589
+ if (hr < 24) return `${hr}小时前`;
26590
+ const day = Math.floor(hr / 24);
26591
+ if (day < 30) return `${day}天前`;
26592
+ return new Date(ms).toLocaleDateString("zh-CN");
26593
+ }
26485
26594
  async function fetchUiOptions() {
26486
26595
  const fallback = {
26487
26596
  showQuickNote: true,
26488
26597
  defaultTag: "inbox"
26489
26598
  };
26490
26599
  try {
26491
- const res = await fetch(STATUS_ENDPOINT$1, { signal: AbortSignal.timeout(5e3) });
26600
+ const res = await fetch(STATUS_ENDPOINT$2, { signal: AbortSignal.timeout(5e3) });
26492
26601
  if (!res.ok) return fallback;
26493
26602
  const payload = await res.json();
26494
26603
  return {
@@ -26500,7 +26609,7 @@ async function fetchUiOptions() {
26500
26609
  }
26501
26610
  }
26502
26611
  /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
26503
- function buildTagEditor() {
26612
+ function buildTagEditor(opts = {}) {
26504
26613
  const wrap = document.createElement("div");
26505
26614
  wrap.className = "dsh-tw-note-tags";
26506
26615
  const chipWrap = document.createElement("div");
@@ -26516,6 +26625,9 @@ function buildTagEditor() {
26516
26625
  const hideSuggest = () => {
26517
26626
  suggest.hidden = true;
26518
26627
  };
26628
+ const emit = () => {
26629
+ opts.onChange?.();
26630
+ };
26519
26631
  const renderChips = () => {
26520
26632
  chipWrap.replaceChildren();
26521
26633
  for (const tag of chips) {
@@ -26532,6 +26644,7 @@ function buildTagEditor() {
26532
26644
  if (i >= 0) {
26533
26645
  chips.splice(i, 1);
26534
26646
  renderChips();
26647
+ emit();
26535
26648
  }
26536
26649
  });
26537
26650
  chip.append(x);
@@ -26546,6 +26659,7 @@ function buildTagEditor() {
26546
26659
  renderChips();
26547
26660
  hideSuggest();
26548
26661
  input.focus();
26662
+ emit();
26549
26663
  };
26550
26664
  const commitInput = () => {
26551
26665
  for (const raw of input.value.split(/\s+/)) addTag(raw);
@@ -26589,6 +26703,7 @@ function buildTagEditor() {
26589
26703
  } else if (event.key === "Backspace" && input.value.length === 0 && chips.length > 0) {
26590
26704
  chips.pop();
26591
26705
  renderChips();
26706
+ emit();
26592
26707
  } else if (event.key === "Escape") hideSuggest();
26593
26708
  });
26594
26709
  document.addEventListener("click", (event) => {
@@ -26608,22 +26723,20 @@ function buildTagEditor() {
26608
26723
  renderChips();
26609
26724
  }
26610
26725
  }
26726
+ },
26727
+ setTags: (tags) => {
26728
+ chips.length = 0;
26729
+ for (const tag of Array.isArray(tags) ? tags : []) {
26730
+ const t = typeof tag === "string" ? tag.trim() : "";
26731
+ if (t.length > 0 && !chips.includes(t)) chips.push(t);
26732
+ }
26733
+ input.value = "";
26734
+ renderChips();
26735
+ hideSuggest();
26611
26736
  }
26612
26737
  };
26613
26738
  }
26614
26739
  /**
26615
- * Build the CodeMirror 6 Markdown editor for the quick-note widget. The editor
26616
- * lives inside `.dsh-tw-note-editor`; the widget reads/writes through
26617
- * getValue/setValue and inserts uploaded files at the caret via insertAtCaret.
26618
- * Mod-Enter (save) is routed through `onSave`.
26619
- */
26620
- function buildEditor(onSave) {
26621
- return buildMarkdownEditor({
26622
- placeholder: "写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存",
26623
- onSave
26624
- });
26625
- }
26626
- /**
26627
26740
  * Upload one file to the wiki (raw body, name in ?name=), then insert a
26628
26741
  * Markdown image/link line at the caret of the given editor.
26629
26742
  */
@@ -26652,240 +26765,421 @@ async function uploadInto(file, editor) {
26652
26765
  toast(`上传失败:${err instanceof Error ? err.message : String(err)}`);
26653
26766
  }
26654
26767
  }
26655
- /** Build the whole widget DOM and wire it up (returns root + editor view). */
26656
- function buildWidget() {
26657
- const root = document.createElement("div");
26658
- root.className = "dsh-tw-note";
26659
- const card = document.createElement("div");
26660
- card.className = "dsh-tw-note-card";
26661
- card.hidden = true;
26662
- const head = document.createElement("div");
26663
- head.className = "dsh-tw-note-head";
26664
- const label = document.createElement("span");
26665
- label.className = "dsh-tw-note-label";
26666
- label.textContent = "📝 快速笔记";
26667
- const closeBtn = document.createElement("button");
26668
- closeBtn.type = "button";
26669
- closeBtn.className = "dsh-tw-note-close";
26670
- closeBtn.title = "收起";
26671
- closeBtn.textContent = "✕";
26672
- head.append(label, closeBtn);
26673
- const fields = document.createElement("div");
26674
- fields.className = "dsh-tw-note-fields";
26675
- const titleInput = document.createElement("input");
26676
- titleInput.className = "dsh-tw-note-title";
26677
- titleInput.placeholder = "标题(默认时间戳)";
26678
- const tagEditor = buildTagEditor();
26679
- fields.append(titleInput, tagEditor.el);
26680
- let doSave;
26681
- const editor = buildEditor(() => {
26682
- doSave?.();
26683
- });
26684
- const uploadBtn = document.createElement("button");
26685
- uploadBtn.type = "button";
26686
- uploadBtn.className = "dsh-tw-note-upload";
26687
- uploadBtn.title = "上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)";
26688
- uploadBtn.textContent = "📎 上传";
26689
- const fileInput = document.createElement("input");
26690
- fileInput.type = "file";
26691
- fileInput.multiple = true;
26692
- fileInput.hidden = true;
26693
- uploadBtn.addEventListener("click", () => {
26694
- fileInput.click();
26695
- });
26696
- fileInput.addEventListener("change", () => {
26697
- for (const file of Array.from(fileInput.files ?? [])) uploadInto(file, editor);
26698
- fileInput.value = "";
26699
- });
26700
- let dragDepth = 0;
26701
- editor.el.addEventListener("dragenter", (event) => {
26702
- event.preventDefault();
26703
- dragDepth++;
26704
- editor.el.classList.add("dsh-tw-note-drop");
26705
- });
26706
- editor.el.addEventListener("dragover", (event) => {
26707
- event.preventDefault();
26708
- });
26709
- editor.el.addEventListener("dragleave", (event) => {
26710
- event.preventDefault();
26711
- dragDepth = Math.max(0, dragDepth - 1);
26712
- if (dragDepth === 0) editor.el.classList.remove("dsh-tw-note-drop");
26713
- });
26714
- editor.el.addEventListener("drop", (event) => {
26715
- event.preventDefault();
26716
- dragDepth = 0;
26717
- editor.el.classList.remove("dsh-tw-note-drop");
26718
- const files = event.dataTransfer?.files;
26719
- if (files === void 0 || files.length === 0) return;
26720
- for (const file of Array.from(files)) uploadInto(file, editor);
26721
- });
26722
- const foot = document.createElement("div");
26723
- foot.className = "dsh-tw-note-foot";
26724
- const footLeft = document.createElement("div");
26725
- footLeft.className = "dsh-tw-note-foot-left";
26726
- const hint = document.createElement("span");
26727
- hint.className = "dsh-tw-note-hint";
26728
- hint.textContent = "Ctrl+Enter";
26729
- footLeft.append(uploadBtn, hint);
26730
- const footRight = document.createElement("div");
26731
- footRight.className = "dsh-tw-note-foot-right";
26732
- const edit = document.createElement("button");
26733
- edit.type = "button";
26734
- edit.className = "dsh-tw-note-edit";
26735
- edit.title = "保存并在 TiddlyWiki 原生编辑器中打开";
26736
- edit.textContent = "✏️ 在 TW 中编辑";
26737
- const save = document.createElement("button");
26738
- save.type = "button";
26739
- save.className = "dsh-tw-note-save";
26740
- save.textContent = "保存";
26741
- footRight.append(edit, save);
26742
- foot.append(footLeft, footRight);
26743
- card.append(head, fields, editor.el, foot);
26744
- const toggle = document.createElement("button");
26745
- toggle.type = "button";
26746
- toggle.className = "dsh-tw-note-toggle";
26747
- toggle.textContent = "📝 快速笔记";
26748
- root.append(card, toggle);
26749
- document.body.append(root);
26750
- let defaultTag = "inbox";
26768
+ /**
26769
+ * Create the quick-note card. Lazy: no DOM is created until the first
26770
+ * `open()`, so a hidden widget (`ui.showQuickNote` off, or never used) leaves
26771
+ * no side effects. Returns a handle the "知识库" FAB controls.
26772
+ */
26773
+ function createNoteWidget() {
26774
+ let ui;
26775
+ let disposed = false;
26751
26776
  let opened = false;
26777
+ let defaultTag = "inbox";
26778
+ let draftTimer;
26779
+ let recentOpen = false;
26752
26780
  const resetTitle = () => {
26753
- titleInput.value = timestampTitle();
26781
+ if (ui !== void 0) ui.titleInput.value = timestampTitle();
26754
26782
  };
26755
- const open = async () => {
26756
- card.hidden = false;
26757
- opened = true;
26758
- resetTitle();
26759
- editor.focus();
26760
- defaultTag = (await fetchUiOptions()).defaultTag;
26761
- tagEditor.setDefault(defaultTag);
26783
+ /** Debounced draft auto-save (500ms after the last change). */
26784
+ const scheduleDraft = () => {
26785
+ if (disposed || !opened || ui === void 0) return;
26786
+ if (draftTimer !== void 0) {
26787
+ clearTimeout(draftTimer);
26788
+ draftTimer = void 0;
26789
+ }
26790
+ draftTimer = window.setTimeout(() => {
26791
+ draftTimer = void 0;
26792
+ if (ui === void 0) return;
26793
+ const text = ui.editor.getValue();
26794
+ const title = ui.titleInput.value.trim();
26795
+ if (text.trim().length === 0 && title.length === 0) {
26796
+ clearDraft();
26797
+ return;
26798
+ }
26799
+ persistDraft({
26800
+ text,
26801
+ title,
26802
+ tags: ui.tagEditor.getTags(),
26803
+ savedAt: Date.now()
26804
+ });
26805
+ }, DRAFT_DEBOUNCE_MS);
26762
26806
  };
26763
- const close = () => {
26764
- card.hidden = true;
26765
- opened = false;
26807
+ const hideDraftBanner = () => {
26808
+ if (ui !== void 0) ui.draftBanner.hidden = true;
26766
26809
  };
26767
- toggle.addEventListener("click", () => {
26768
- opened ? close() : open();
26769
- });
26770
- closeBtn.addEventListener("click", close);
26771
- doSave = async () => {
26772
- const text = editor.getValue().trim();
26773
- if (text.length === 0) {
26774
- toast("内容为空,未保存");
26775
- return;
26776
- }
26777
- save.disabled = true;
26778
- save.textContent = "保存中…";
26810
+ const closeRecent = () => {
26811
+ recentOpen = false;
26812
+ if (ui !== void 0) ui.recentWrap.hidden = true;
26813
+ };
26814
+ /** Load a tiddler into the editor (recent picker click). */
26815
+ const loadNote = async (title) => {
26779
26816
  try {
26780
- const res = await fetch(NOTE_ENDPOINT, {
26781
- method: "POST",
26782
- headers: { "content-type": "application/json" },
26783
- body: JSON.stringify({
26784
- title: titleInput.value.trim(),
26785
- tags: tagEditor.getTags(),
26786
- text
26787
- }),
26788
- signal: AbortSignal.timeout(1e4)
26789
- });
26817
+ const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(1e4) });
26790
26818
  const payload = await res.json().catch(() => null);
26791
- if (!res.ok || payload?.ok !== true) {
26792
- toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`);
26819
+ if (!res.ok || payload?.ok !== true || typeof payload.title !== "string") {
26820
+ toast(`读取失败:${payload?.notFound === true ? "不存在" : payload?.error ?? `HTTP ${res.status}`}`);
26793
26821
  return;
26794
26822
  }
26795
- editor.setValue("");
26796
- resetTitle();
26797
- toast(`已保存「${payload.title ?? titleInput.value}」`);
26798
- close();
26823
+ if (ui === void 0) return;
26824
+ ui.editor.setValue(payload.text ?? "");
26825
+ ui.titleInput.value = payload.title;
26826
+ ui.tagEditor.setTags(payload.tags ?? []);
26827
+ hideDraftBanner();
26828
+ closeRecent();
26829
+ toast(`已载入「${payload.title}」`);
26830
+ ui.editor.focus();
26799
26831
  } catch (err) {
26800
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`);
26801
- } finally {
26802
- save.disabled = false;
26803
- save.textContent = "保存";
26832
+ toast(`读取失败:${err instanceof Error ? err.message : String(err)}`);
26804
26833
  }
26805
26834
  };
26806
- save.addEventListener("click", () => {
26807
- doSave?.();
26808
- });
26809
- /** Save (if non-empty) and open the tiddler in TW's native editor. */
26810
- const doEdit = async () => {
26811
- const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle();
26812
- const text = editor.getValue();
26813
- const tags = tagEditor.getTags();
26814
- edit.disabled = true;
26815
- edit.textContent = "打开中…";
26816
- try {
26817
- const res = await fetch(EDIT_ENDPOINT, {
26818
- method: "POST",
26819
- headers: { "content-type": "application/json" },
26820
- body: JSON.stringify({
26821
- title,
26822
- tags,
26823
- text
26824
- }),
26825
- signal: AbortSignal.timeout(1e4)
26826
- });
26827
- const payload = await res.json().catch(() => null);
26828
- if (!res.ok || payload?.ok !== true) {
26829
- toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`);
26830
- return;
26835
+ /** Toggle the recent-notes dropdown (fetches lazily each open). */
26836
+ const toggleRecent = () => {
26837
+ if (ui === void 0) return;
26838
+ if (recentOpen) {
26839
+ closeRecent();
26840
+ return;
26841
+ }
26842
+ recentOpen = true;
26843
+ ui.recentWrap.hidden = false;
26844
+ ui.recentWrap.textContent = "";
26845
+ const loading = document.createElement("div");
26846
+ loading.className = "dsh-tw-note-recent-muted";
26847
+ loading.textContent = "加载中…";
26848
+ ui.recentWrap.append(loading);
26849
+ (async () => {
26850
+ try {
26851
+ const payload = await (await fetch(`${RECENT_ENDPOINT}?limit=15`, { signal: AbortSignal.timeout(1e4) })).json().catch(() => null);
26852
+ if (!recentOpen || ui === void 0) return;
26853
+ ui.recentWrap.replaceChildren();
26854
+ const items = payload?.ok === true ? payload.items ?? [] : [];
26855
+ if (items.length === 0) {
26856
+ const empty = document.createElement("div");
26857
+ empty.className = "dsh-tw-note-recent-muted";
26858
+ empty.textContent = payload?.ok === true ? "暂无笔记" : `加载失败:${payload?.error ?? "未知"}`;
26859
+ ui.recentWrap.append(empty);
26860
+ return;
26861
+ }
26862
+ for (const item of items) {
26863
+ const row = document.createElement("div");
26864
+ row.className = "dsh-tw-note-recent-item";
26865
+ row.title = item.snippet || item.title;
26866
+ const name = document.createElement("span");
26867
+ name.className = "dsh-tw-note-recent-name";
26868
+ name.textContent = item.title;
26869
+ const meta = document.createElement("span");
26870
+ meta.className = "dsh-tw-note-recent-meta";
26871
+ const bits = [relativeTime(item.modified)];
26872
+ if (item.tags.length > 0) bits.unshift(item.tags.slice(0, 3).join(","));
26873
+ meta.textContent = bits.join(" · ");
26874
+ row.append(name, meta);
26875
+ row.addEventListener("click", () => {
26876
+ loadNote(item.title);
26877
+ });
26878
+ ui.recentWrap.append(row);
26879
+ }
26880
+ } catch (err) {
26881
+ if (!recentOpen || ui === void 0) return;
26882
+ ui.recentWrap.replaceChildren();
26883
+ const empty = document.createElement("div");
26884
+ empty.className = "dsh-tw-note-recent-muted";
26885
+ empty.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`;
26886
+ ui.recentWrap.append(empty);
26831
26887
  }
26832
- if (typeof payload.twUrl !== "string" || typeof payload.draftTitle !== "string") {
26833
- toast("打开失败:服务未返回编辑器地址");
26888
+ })();
26889
+ };
26890
+ /** Build the whole card DOM once, then wire every interaction. */
26891
+ const build = () => {
26892
+ if (ui !== void 0) return;
26893
+ const root = document.createElement("div");
26894
+ root.className = "dsh-tw-note";
26895
+ root.hidden = true;
26896
+ const card = document.createElement("div");
26897
+ card.className = "dsh-tw-note-card";
26898
+ const head = document.createElement("div");
26899
+ head.className = "dsh-tw-note-head";
26900
+ const label = document.createElement("span");
26901
+ label.className = "dsh-tw-note-label";
26902
+ label.textContent = "📝 快速笔记";
26903
+ const closeBtn = document.createElement("button");
26904
+ closeBtn.type = "button";
26905
+ closeBtn.className = "dsh-tw-note-close";
26906
+ closeBtn.title = "收起";
26907
+ closeBtn.textContent = "✕";
26908
+ head.append(label, closeBtn);
26909
+ const draftBanner = document.createElement("div");
26910
+ draftBanner.className = "dsh-tw-note-draft";
26911
+ draftBanner.hidden = true;
26912
+ const bannerText = document.createElement("span");
26913
+ bannerText.className = "dsh-tw-note-draft-text";
26914
+ bannerText.textContent = "已恢复未保存草稿";
26915
+ const discardBtn = document.createElement("button");
26916
+ discardBtn.type = "button";
26917
+ discardBtn.className = "dsh-tw-note-draft-discard";
26918
+ discardBtn.textContent = "丢弃";
26919
+ draftBanner.append(bannerText, discardBtn);
26920
+ const fields = document.createElement("div");
26921
+ fields.className = "dsh-tw-note-fields";
26922
+ const titleInput = document.createElement("input");
26923
+ titleInput.className = "dsh-tw-note-title";
26924
+ titleInput.placeholder = "标题(默认时间戳)";
26925
+ const tagEditor = buildTagEditor({ onChange: scheduleDraft });
26926
+ fields.append(titleInput, tagEditor.el);
26927
+ titleInput.addEventListener("input", scheduleDraft);
26928
+ let doSave;
26929
+ const editor = buildMarkdownEditor({
26930
+ placeholder: "写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存",
26931
+ onSave: () => {
26932
+ doSave?.();
26933
+ },
26934
+ onChange: scheduleDraft
26935
+ });
26936
+ const uploadBtn = document.createElement("button");
26937
+ uploadBtn.type = "button";
26938
+ uploadBtn.className = "dsh-tw-note-upload";
26939
+ uploadBtn.title = "上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)";
26940
+ uploadBtn.textContent = "📎 上传";
26941
+ const fileInput = document.createElement("input");
26942
+ fileInput.type = "file";
26943
+ fileInput.multiple = true;
26944
+ fileInput.hidden = true;
26945
+ uploadBtn.addEventListener("click", () => {
26946
+ fileInput.click();
26947
+ });
26948
+ fileInput.addEventListener("change", () => {
26949
+ for (const file of Array.from(fileInput.files ?? [])) uploadInto(file, editor);
26950
+ fileInput.value = "";
26951
+ });
26952
+ let dragDepth = 0;
26953
+ editor.el.addEventListener("dragenter", (event) => {
26954
+ event.preventDefault();
26955
+ dragDepth++;
26956
+ editor.el.classList.add("dsh-tw-note-drop");
26957
+ });
26958
+ editor.el.addEventListener("dragover", (event) => {
26959
+ event.preventDefault();
26960
+ });
26961
+ editor.el.addEventListener("dragleave", (event) => {
26962
+ event.preventDefault();
26963
+ dragDepth = Math.max(0, dragDepth - 1);
26964
+ if (dragDepth === 0) editor.el.classList.remove("dsh-tw-note-drop");
26965
+ });
26966
+ editor.el.addEventListener("drop", (event) => {
26967
+ event.preventDefault();
26968
+ dragDepth = 0;
26969
+ editor.el.classList.remove("dsh-tw-note-drop");
26970
+ const files = event.dataTransfer?.files;
26971
+ if (files === void 0 || files.length === 0) return;
26972
+ for (const file of Array.from(files)) uploadInto(file, editor);
26973
+ });
26974
+ const foot = document.createElement("div");
26975
+ foot.className = "dsh-tw-note-foot";
26976
+ const footLeft = document.createElement("div");
26977
+ footLeft.className = "dsh-tw-note-foot-left";
26978
+ const hint = document.createElement("span");
26979
+ hint.className = "dsh-tw-note-hint";
26980
+ hint.textContent = "Ctrl+Enter";
26981
+ const recentBtn = document.createElement("button");
26982
+ recentBtn.type = "button";
26983
+ recentBtn.className = "dsh-tw-note-recent-btn";
26984
+ recentBtn.title = "最近修改的笔记,点击载入继续编辑";
26985
+ recentBtn.textContent = "🕘 最近";
26986
+ recentBtn.addEventListener("click", toggleRecent);
26987
+ footLeft.append(uploadBtn, recentBtn, hint);
26988
+ const footRight = document.createElement("div");
26989
+ footRight.className = "dsh-tw-note-foot-right";
26990
+ const editBtn = document.createElement("button");
26991
+ editBtn.type = "button";
26992
+ editBtn.className = "dsh-tw-note-edit";
26993
+ editBtn.title = "保存并在 TiddlyWiki 原生编辑器中打开";
26994
+ editBtn.textContent = "✏️ 在 TW 中编辑";
26995
+ const saveBtn = document.createElement("button");
26996
+ saveBtn.type = "button";
26997
+ saveBtn.className = "dsh-tw-note-save";
26998
+ saveBtn.textContent = "保存";
26999
+ footRight.append(editBtn, saveBtn);
27000
+ foot.append(footLeft, footRight);
27001
+ const recentWrap = document.createElement("div");
27002
+ recentWrap.className = "dsh-tw-note-recent";
27003
+ recentWrap.hidden = true;
27004
+ card.append(head, draftBanner, fields, editor.el, foot);
27005
+ root.append(card, recentWrap);
27006
+ document.body.append(root);
27007
+ ui = {
27008
+ root,
27009
+ card,
27010
+ editor,
27011
+ titleInput,
27012
+ tagEditor,
27013
+ saveBtn,
27014
+ editBtn,
27015
+ draftBanner,
27016
+ recentBtn,
27017
+ recentWrap
27018
+ };
27019
+ const close = () => {
27020
+ opened = false;
27021
+ root.hidden = true;
27022
+ closeRecent();
27023
+ };
27024
+ const saveDone = () => {
27025
+ clearDraft();
27026
+ hideDraftBanner();
27027
+ editor.setValue("");
27028
+ titleInput.value = timestampTitle();
27029
+ tagEditor.setTags([]);
27030
+ close();
27031
+ };
27032
+ doSave = async () => {
27033
+ const text = editor.getValue().trim();
27034
+ if (text.length === 0) {
27035
+ toast("内容为空,未保存");
26834
27036
  return;
26835
27037
  }
26836
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title);
26837
- toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`);
26838
- } catch (err) {
26839
- toast(`打开失败:${err instanceof Error ? err.message : String(err)}`);
26840
- } finally {
26841
- edit.disabled = false;
26842
- edit.textContent = "✏️ 在 TW 中编辑";
26843
- }
27038
+ saveBtn.disabled = true;
27039
+ saveBtn.textContent = "保存中…";
27040
+ try {
27041
+ const res = await fetch(NOTE_ENDPOINT, {
27042
+ method: "POST",
27043
+ headers: { "content-type": "application/json" },
27044
+ body: JSON.stringify({
27045
+ title: titleInput.value.trim(),
27046
+ tags: tagEditor.getTags(),
27047
+ text
27048
+ }),
27049
+ signal: AbortSignal.timeout(1e4)
27050
+ });
27051
+ const payload = await res.json().catch(() => null);
27052
+ if (!res.ok || payload?.ok !== true) {
27053
+ toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`);
27054
+ return;
27055
+ }
27056
+ saveDone();
27057
+ toast(`已保存「${payload.title ?? titleInput.value}」`);
27058
+ } catch (err) {
27059
+ toast(`保存失败:${err instanceof Error ? err.message : String(err)}`);
27060
+ } finally {
27061
+ saveBtn.disabled = false;
27062
+ saveBtn.textContent = "保存";
27063
+ }
27064
+ };
27065
+ saveBtn.addEventListener("click", () => {
27066
+ doSave?.();
27067
+ });
27068
+ /** Save (if non-empty) and open the tiddler in TW's native editor. */
27069
+ const doEdit = async () => {
27070
+ const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle();
27071
+ const text = editor.getValue();
27072
+ const tags = tagEditor.getTags();
27073
+ editBtn.disabled = true;
27074
+ editBtn.textContent = "打开中…";
27075
+ try {
27076
+ const res = await fetch(EDIT_ENDPOINT, {
27077
+ method: "POST",
27078
+ headers: { "content-type": "application/json" },
27079
+ body: JSON.stringify({
27080
+ title,
27081
+ tags,
27082
+ text
27083
+ }),
27084
+ signal: AbortSignal.timeout(1e4)
27085
+ });
27086
+ const payload = await res.json().catch(() => null);
27087
+ if (!res.ok || payload?.ok !== true) {
27088
+ toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`);
27089
+ return;
27090
+ }
27091
+ if (typeof payload.twUrl !== "string" || typeof payload.draftTitle !== "string") {
27092
+ toast("打开失败:服务未返回编辑器地址");
27093
+ return;
27094
+ }
27095
+ clearDraft();
27096
+ hideDraftBanner();
27097
+ openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title);
27098
+ toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`);
27099
+ } catch (err) {
27100
+ toast(`打开失败:${err instanceof Error ? err.message : String(err)}`);
27101
+ } finally {
27102
+ editBtn.disabled = false;
27103
+ editBtn.textContent = "✏️ 在 TW 中编辑";
27104
+ }
27105
+ };
27106
+ editBtn.addEventListener("click", () => {
27107
+ doEdit();
27108
+ });
27109
+ closeBtn.addEventListener("click", close);
27110
+ document.addEventListener("click", (event) => {
27111
+ if (!opened) return;
27112
+ const target = event.target;
27113
+ if (root.contains(target)) return;
27114
+ if (recentWrap.contains(target)) return;
27115
+ close();
27116
+ }, true);
27117
+ discardBtn.addEventListener("click", () => {
27118
+ clearDraft();
27119
+ hideDraftBanner();
27120
+ editor.setValue("");
27121
+ titleInput.value = timestampTitle();
27122
+ tagEditor.setTags([]);
27123
+ toast("已丢弃草稿");
27124
+ });
26844
27125
  };
26845
- edit.addEventListener("click", () => {
26846
- doEdit();
26847
- });
26848
27126
  return {
26849
- root,
26850
- editorView: editor.view
26851
- };
26852
- }
26853
- /**
26854
- * Mount the floating quick-note widget. Fetches /status first: when
26855
- * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
26856
- * Returns a disposer that removes it (and destroys the CodeMirror view).
26857
- */
26858
- function mountNoteWidget() {
26859
- let disposed = false;
26860
- let root;
26861
- let editorView;
26862
- const disposer = () => {
26863
- disposed = true;
26864
- editorView?.destroy();
26865
- root?.remove();
26866
- document.querySelector(".dsh-tw-toast")?.remove();
27127
+ async open() {
27128
+ if (disposed) return;
27129
+ build();
27130
+ if (ui === void 0 || opened) return;
27131
+ opened = true;
27132
+ ui.root.hidden = false;
27133
+ const draft = loadDraft();
27134
+ if (draft !== null && (draft.text.trim().length > 0 || draft.title.trim().length > 0)) {
27135
+ ui.editor.setValue(draft.text);
27136
+ ui.titleInput.value = draft.title;
27137
+ ui.tagEditor.setTags(draft.tags);
27138
+ ui.draftBanner.hidden = false;
27139
+ } else {
27140
+ resetTitle();
27141
+ defaultTag = (await fetchUiOptions()).defaultTag;
27142
+ ui.tagEditor.setDefault(defaultTag);
27143
+ }
27144
+ ui.editor.focus();
27145
+ },
27146
+ close() {
27147
+ if (ui === void 0) return;
27148
+ opened = false;
27149
+ ui.root.hidden = true;
27150
+ closeRecent();
27151
+ },
27152
+ async toggle() {
27153
+ if (opened) this.close();
27154
+ else await this.open();
27155
+ },
27156
+ isOpen() {
27157
+ return opened;
27158
+ },
27159
+ dispose() {
27160
+ disposed = true;
27161
+ if (draftTimer !== void 0) {
27162
+ clearTimeout(draftTimer);
27163
+ draftTimer = void 0;
27164
+ }
27165
+ ui?.editor.view.destroy();
27166
+ ui?.root.remove();
27167
+ document.querySelector(".dsh-tw-toast")?.remove();
27168
+ ui = void 0;
27169
+ }
26867
27170
  };
26868
- (async () => {
26869
- const ui = await fetchUiOptions();
26870
- if (disposed || !ui.showQuickNote) return;
26871
- const widget = buildWidget();
26872
- root = widget.root;
26873
- editorView = widget.editorView;
26874
- })();
26875
- return disposer;
26876
27171
  }
26877
27172
  //#endregion
26878
27173
  //#region src/client/sync-button.ts
26879
27174
  /**
26880
- * Floating "sync" button (bottom-right) — one click pulls + commits + pushes
26881
- * the wiki git repository and reports the result, with a live status dot so
26882
- * the human always sees whether the knowledge base is in sync.
27175
+ * Sync controller — the git pull→commit→push logic + live git status, decoupled
27176
+ * from any DOM. In v0.5 the visual (status dot, 同步 entry, syncing spinner)
27177
+ * lives in the "知识库" FAB (knowledge-fab.ts); this module only owns state,
27178
+ * polling and the one-click sync call.
26883
27179
  *
26884
- * - honors `ui.showSyncButton`: the button is mounted async and never created
26885
- * when the option is off (settings page toggle);
26886
27180
  * - polls /dsh-tiddlywiki/status every 30s for the git state;
26887
- * - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
26888
- * outcome, then refreshes the dot immediately.
27181
+ * - `trigger()` POSTs /dsh-tiddlywiki/sync (pull → commit → push), returns the
27182
+ * fresh state and notifies subscribers.
26889
27183
  *
26890
27184
  * Status dot mapping (git summary from /status):
26891
27185
  * offline → gray (no repo / service unreachable)
@@ -26893,13 +27187,11 @@ function mountNoteWidget() {
26893
27187
  * behind → red (remote has commits we don't — pull will rebase)
26894
27188
  * clean → green (worktree clean)
26895
27189
  *
26896
- * @module dsh-tiddlywiki/client/sync-button
27190
+ * @module dsh-tiddlywiki/client/sync-controller
26897
27191
  */
26898
- const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
27192
+ const STATUS_ENDPOINT$1 = "/dsh-tiddlywiki/status";
26899
27193
  const SYNC_ENDPOINT$1 = "/dsh-tiddlywiki/sync";
26900
27194
  const POLL_MS = 3e4;
26901
- /** Inline sync icon (circular arrows, stroke-based, theme-colored). */
26902
- const SYNC_ICON = "<svg viewBox=\"0 0 16 16\" width=\"15\" height=\"15\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M13 8a5 5 0 1 1-1.4-3.5\"/><path d=\"M13 2.5v3h-3\"/></svg>";
26903
27195
  function pad(n) {
26904
27196
  return n < 10 ? `0${n}` : String(n);
26905
27197
  }
@@ -26909,83 +27201,83 @@ function clock(date) {
26909
27201
  }
26910
27202
  async function fetchStatus() {
26911
27203
  try {
26912
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8e3) });
27204
+ const res = await fetch(STATUS_ENDPOINT$1, { signal: AbortSignal.timeout(8e3) });
26913
27205
  if (!res.ok) return null;
26914
27206
  return await res.json();
26915
27207
  } catch {
26916
27208
  return null;
26917
27209
  }
26918
27210
  }
27211
+ /** Map a git summary onto a SyncStateView. */
27212
+ function buildState(payload, lastSync) {
27213
+ const git = payload?.git;
27214
+ const bits = ["同步知识库"];
27215
+ let state = "offline";
27216
+ let label = "离线";
27217
+ if (git === null || git === void 0 || git.exists !== true) bits.push("git 仓库不可用");
27218
+ else {
27219
+ bits.push(`分支 ${git.branch ?? "?"}`);
27220
+ if (typeof git.lastCommit === "string") bits.push(git.lastCommit);
27221
+ const behind = typeof git.behind === "number" ? git.behind : 0;
27222
+ const ahead = typeof git.ahead === "number" ? git.ahead : 0;
27223
+ if (ahead > 0) bits.push(`领先 ${ahead}`);
27224
+ if (behind > 0) bits.push(`落后 ${behind}`);
27225
+ if (git.dirty === true) {
27226
+ state = "dirty";
27227
+ label = "待提交";
27228
+ bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`);
27229
+ } else if (behind > 0) {
27230
+ state = "behind";
27231
+ label = "可更新";
27232
+ } else {
27233
+ state = "clean";
27234
+ label = "已同步";
27235
+ }
27236
+ }
27237
+ if (lastSync !== void 0) bits.push(`上次同步 ${clock(lastSync)}`);
27238
+ return {
27239
+ state,
27240
+ label,
27241
+ tooltip: bits.join(" · "),
27242
+ ...lastSync !== void 0 ? { lastSync } : {}
27243
+ };
27244
+ }
26919
27245
  /**
26920
- * Mount the floating sync button. Fetches /status first: when
26921
- * `ui.showSyncButton` is off the button is never created. Returns a disposer.
27246
+ * Create the sync controller: starts a 30s status poll immediately and exposes
27247
+ * `trigger()` for the FAB's 同步 entry. No DOM is created here.
26922
27248
  */
26923
- function mountSyncButton() {
26924
- let disposed = false;
26925
- let root;
26926
- let dot;
26927
- let label;
27249
+ function createSyncController() {
27250
+ let state = {
27251
+ state: "offline",
27252
+ label: "离线",
27253
+ tooltip: "同步知识库"
27254
+ };
26928
27255
  let lastSync;
26929
27256
  let timer;
26930
- const build = () => {
26931
- const btn = document.createElement("button");
26932
- btn.type = "button";
26933
- btn.className = "dsh-tw-sync";
26934
- btn.title = "同步知识库(pull → commit → push)";
26935
- const icon = document.createElement("span");
26936
- icon.className = "dsh-tw-sync-icon";
26937
- icon.innerHTML = SYNC_ICON;
26938
- dot = document.createElement("span");
26939
- dot.className = "dsh-tw-sync-dot";
26940
- dot.dataset.state = "offline";
26941
- label = document.createElement("span");
26942
- label.className = "dsh-tw-sync-label";
26943
- label.textContent = "同步";
26944
- btn.append(icon, label, dot);
26945
- btn.addEventListener("click", () => {
26946
- doSync();
26947
- });
26948
- document.body.append(btn);
26949
- return btn;
27257
+ let syncing = false;
27258
+ const listeners = /* @__PURE__ */ new Set();
27259
+ const emit = () => {
27260
+ for (const cb of [...listeners]) cb();
26950
27261
  };
26951
- /** Map a git summary onto the dot + label + tooltip. */
26952
27262
  const applyStatus = (payload) => {
26953
- if (root === void 0 || dot === void 0 || label === void 0) return;
26954
- const git = payload?.git;
26955
- const bits = ["同步知识库"];
26956
- let state = "offline";
26957
- let word = "离线";
26958
- if (git === null || git === void 0 || git.exists !== true) bits.push("git 仓库不可用");
26959
- else {
26960
- bits.push(`分支 ${git.branch ?? "?"}`);
26961
- if (typeof git.lastCommit === "string") bits.push(git.lastCommit);
26962
- const behind = typeof git.behind === "number" ? git.behind : 0;
26963
- const ahead = typeof git.ahead === "number" ? git.ahead : 0;
26964
- if (ahead > 0) bits.push(`领先 ${ahead}`);
26965
- if (behind > 0) bits.push(`落后 ${behind}`);
26966
- if (git.dirty === true) {
26967
- state = "dirty";
26968
- word = "待提交";
26969
- bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`);
26970
- } else if (behind > 0) {
26971
- state = "behind";
26972
- word = "可更新";
26973
- } else {
26974
- state = "clean";
26975
- word = "已同步";
26976
- }
27263
+ const next = buildState(payload, lastSync);
27264
+ if (next.state !== state.state || next.tooltip !== state.tooltip) {
27265
+ state = next;
27266
+ emit();
26977
27267
  }
26978
- if (lastSync !== void 0) bits.push(`上次同步 ${clock(lastSync)}`);
26979
- root.title = bits.join(" · ");
26980
- dot.dataset.state = state;
26981
- label.textContent = word;
27268
+ };
27269
+ const poll = async () => {
27270
+ applyStatus(await fetchStatus());
26982
27271
  };
26983
27272
  const doSync = async () => {
26984
- if (root === void 0) return;
26985
- root.disabled = true;
26986
- root.classList.add("dsh-tw-sync-spin");
26987
- if (label !== void 0) label.textContent = "同步中…";
26988
- if (dot !== void 0) dot.dataset.state = "syncing";
27273
+ if (syncing) return state;
27274
+ syncing = true;
27275
+ state = {
27276
+ ...state,
27277
+ state: "syncing",
27278
+ label: "同步中…"
27279
+ };
27280
+ emit();
26989
27281
  try {
26990
27282
  const res = await fetch(SYNC_ENDPOINT$1, {
26991
27283
  method: "POST",
@@ -27003,29 +27295,249 @@ function mountSyncButton() {
27003
27295
  }
27004
27296
  toast(`同步完成:${payload.message ?? "OK"}${detail}`);
27005
27297
  }
27006
- applyStatus(await fetchStatus());
27298
+ await poll();
27007
27299
  } catch (err) {
27008
27300
  toast(`同步失败:${err instanceof Error ? err.message : String(err)}`);
27009
27301
  } finally {
27010
- root.disabled = false;
27011
- root.classList.remove("dsh-tw-sync-spin");
27302
+ syncing = false;
27303
+ await poll();
27012
27304
  }
27305
+ return state;
27013
27306
  };
27014
- const poll = async () => {
27015
- applyStatus(await fetchStatus());
27307
+ poll();
27308
+ timer = window.setInterval(() => {
27309
+ poll();
27310
+ }, POLL_MS);
27311
+ return {
27312
+ getState: () => state,
27313
+ trigger: doSync,
27314
+ subscribe(cb) {
27315
+ listeners.add(cb);
27316
+ return () => {
27317
+ listeners.delete(cb);
27318
+ };
27319
+ },
27320
+ dispose() {
27321
+ if (timer !== void 0) {
27322
+ clearInterval(timer);
27323
+ timer = void 0;
27324
+ }
27325
+ listeners.clear();
27326
+ }
27327
+ };
27328
+ }
27329
+ //#endregion
27330
+ //#region src/client/knowledge-fab.ts
27331
+ const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
27332
+ /** Book icon (same visual family as the sidebar entry). */
27333
+ const BOOK_ICON = "<svg viewBox=\"0 0 16 16\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z\"/><path d=\"M6 6h4M6 8.5h2.5\"/></svg>";
27334
+ async function fetchUiFlags() {
27335
+ try {
27336
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
27337
+ if (!res.ok) return {
27338
+ showQuickNote: true,
27339
+ showPanelStatus: true,
27340
+ showSyncButton: true
27341
+ };
27342
+ const p = await res.json();
27343
+ return {
27344
+ showQuickNote: p.ui?.showQuickNote !== false,
27345
+ showPanelStatus: p.ui?.showPanelStatus !== false,
27346
+ showSyncButton: p.ui?.showSyncButton !== false
27347
+ };
27348
+ } catch {
27349
+ return {
27350
+ showQuickNote: true,
27351
+ showPanelStatus: true,
27352
+ showSyncButton: true
27353
+ };
27354
+ }
27355
+ }
27356
+ /** Fetch the TW service health for the menu's status line. */
27357
+ async function fetchTwStatus() {
27358
+ try {
27359
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8e3) });
27360
+ if (!res.ok) return {
27361
+ state: "failed",
27362
+ text: "状态不可达"
27363
+ };
27364
+ const p = await res.json();
27365
+ if (p.status === "running") return {
27366
+ state: "running",
27367
+ text: `TW 在线 · ${p.url ?? ""}`
27368
+ };
27369
+ if (p.status === "starting") return {
27370
+ state: "starting",
27371
+ text: "TW 启动中…"
27372
+ };
27373
+ return {
27374
+ state: "failed",
27375
+ text: p.error ?? `TW 状态:${p.status ?? "?"}`
27376
+ };
27377
+ } catch {
27378
+ return {
27379
+ state: "failed",
27380
+ text: "状态不可达"
27381
+ };
27382
+ }
27383
+ }
27384
+ /**
27385
+ * Mount the knowledge FAB. Fetches /status first to read the ui.* flags; when
27386
+ * every entry is disabled the FAB is never created. Returns a disposer.
27387
+ */
27388
+ function mountKnowledgeFab(state, note, sync) {
27389
+ let disposed = false;
27390
+ let root;
27391
+ let fabBtn;
27392
+ let dot;
27393
+ let menu;
27394
+ let twStatusEl;
27395
+ let twDot;
27396
+ let panelLabel;
27397
+ let menuOpen = false;
27398
+ let gitRowDot;
27399
+ let gitRowText;
27400
+ const closeMenu = () => {
27401
+ menuOpen = false;
27402
+ if (menu !== void 0) menu.hidden = true;
27403
+ };
27404
+ /** Refresh the git dot + tooltip from the controller (FAB dot + menu row). */
27405
+ const renderDot = () => {
27406
+ const s = sync.getState();
27407
+ if (dot !== void 0) dot.dataset.state = s.state;
27408
+ if (fabBtn !== void 0) fabBtn.title = `知识库 · ${s.tooltip}`;
27409
+ if (gitRowDot !== void 0) gitRowDot.dataset.state = s.state;
27410
+ if (gitRowText !== void 0) gitRowText.textContent = s.tooltip;
27411
+ };
27412
+ /** Refresh the TW service status line in the menu. */
27413
+ const refreshTwStatus = async () => {
27414
+ const info = await fetchTwStatus();
27415
+ if (disposed) return;
27416
+ if (twStatusEl !== void 0) twStatusEl.textContent = info.text;
27417
+ if (twDot !== void 0) twDot.dataset.state = info.state;
27418
+ };
27419
+ const build = (flags) => {
27420
+ if (disposed) return;
27421
+ root = document.createElement("div");
27422
+ root.className = "dsh-tw-fab-wrap";
27423
+ menu = document.createElement("div");
27424
+ menu.className = "dsh-tw-fab-menu";
27425
+ menu.hidden = true;
27426
+ if (flags.showPanelStatus) {
27427
+ const twRow = document.createElement("div");
27428
+ twRow.className = "dsh-tw-fab-status";
27429
+ twDot = document.createElement("span");
27430
+ twDot.className = "dsh-tw-fab-status-dot";
27431
+ twDot.dataset.state = "unknown";
27432
+ twStatusEl = document.createElement("span");
27433
+ twStatusEl.className = "dsh-tw-fab-status-text";
27434
+ twStatusEl.textContent = "TiddlyWiki 服务…";
27435
+ twRow.append(twDot, twStatusEl);
27436
+ menu.append(twRow);
27437
+ }
27438
+ if (flags.showSyncButton) {
27439
+ const gitRow = document.createElement("div");
27440
+ gitRow.className = "dsh-tw-fab-status";
27441
+ gitRowDot = document.createElement("span");
27442
+ gitRowDot.className = "dsh-tw-fab-status-dot";
27443
+ gitRowDot.dataset.state = sync.getState().state;
27444
+ gitRowText = document.createElement("span");
27445
+ gitRowText.className = "dsh-tw-fab-status-text";
27446
+ gitRowText.textContent = sync.getState().tooltip;
27447
+ gitRow.append(gitRowDot, gitRowText);
27448
+ menu.append(gitRow);
27449
+ }
27450
+ if (flags.showQuickNote) {
27451
+ const item = document.createElement("button");
27452
+ item.type = "button";
27453
+ item.className = "dsh-tw-fab-item";
27454
+ item.textContent = "📝 快速笔记";
27455
+ item.addEventListener("click", () => {
27456
+ closeMenu();
27457
+ note.toggle();
27458
+ });
27459
+ menu.append(item);
27460
+ }
27461
+ if (flags.showPanelStatus) {
27462
+ panelLabel = document.createElement("span");
27463
+ panelLabel.textContent = "打开 TW 面板";
27464
+ const item = document.createElement("button");
27465
+ item.type = "button";
27466
+ item.className = "dsh-tw-fab-item";
27467
+ item.append(document.createTextNode("🖥 "), panelLabel);
27468
+ item.addEventListener("click", () => {
27469
+ closeMenu();
27470
+ state.toggle();
27471
+ });
27472
+ menu.append(item);
27473
+ const reload = document.createElement("button");
27474
+ reload.type = "button";
27475
+ reload.className = "dsh-tw-fab-item";
27476
+ reload.textContent = "🔄 重载 TW 面板";
27477
+ reload.addEventListener("click", () => {
27478
+ closeMenu();
27479
+ document.dispatchEvent(new CustomEvent("dsh-tw-panel-reload"));
27480
+ refreshTwStatus();
27481
+ });
27482
+ menu.append(reload);
27483
+ }
27484
+ if (flags.showSyncButton) {
27485
+ const item = document.createElement("button");
27486
+ item.type = "button";
27487
+ item.className = "dsh-tw-fab-item";
27488
+ item.textContent = "🔁 同步";
27489
+ item.addEventListener("click", () => {
27490
+ closeMenu();
27491
+ sync.trigger();
27492
+ });
27493
+ menu.append(item);
27494
+ }
27495
+ fabBtn = document.createElement("button");
27496
+ fabBtn.type = "button";
27497
+ fabBtn.className = "dsh-tw-fab";
27498
+ fabBtn.setAttribute("aria-label", "TiddlyWiki 知识库");
27499
+ const icon = document.createElement("span");
27500
+ icon.className = "dsh-tw-fab-icon";
27501
+ icon.innerHTML = BOOK_ICON;
27502
+ dot = document.createElement("span");
27503
+ dot.className = "dsh-tw-fab-dot";
27504
+ dot.dataset.state = sync.getState().state;
27505
+ fabBtn.append(icon, dot);
27506
+ fabBtn.addEventListener("click", () => {
27507
+ if (menu === void 0) return;
27508
+ if (menuOpen) {
27509
+ closeMenu();
27510
+ return;
27511
+ }
27512
+ menuOpen = true;
27513
+ menu.hidden = false;
27514
+ renderDot();
27515
+ refreshTwStatus();
27516
+ });
27517
+ root.append(menu, fabBtn);
27518
+ document.body.append(root);
27519
+ renderDot();
27520
+ document.addEventListener("click", (event) => {
27521
+ if (!menuOpen) return;
27522
+ const target = event.target;
27523
+ if (root !== void 0 && root.contains(target)) return;
27524
+ closeMenu();
27525
+ }, true);
27016
27526
  };
27527
+ const unsubPanel = state.subscribe(() => {
27528
+ if (panelLabel !== void 0) panelLabel.textContent = state.isOpen() ? "收起 TW 面板" : "打开 TW 面板";
27529
+ });
27530
+ const unsubSync = sync.subscribe(renderDot);
27017
27531
  (async () => {
27018
- const payload = await fetchStatus();
27019
- if (disposed || payload?.ui?.showSyncButton === false) return;
27020
- root = build();
27021
- applyStatus(payload);
27022
- timer = window.setInterval(() => {
27023
- poll();
27024
- }, POLL_MS);
27532
+ const flags = await fetchUiFlags();
27533
+ if (disposed) return;
27534
+ if (!flags.showQuickNote && !flags.showPanelStatus && !flags.showSyncButton) return;
27535
+ build(flags);
27025
27536
  })();
27026
27537
  return () => {
27027
27538
  disposed = true;
27028
- if (timer !== void 0) window.clearInterval(timer);
27539
+ unsubPanel();
27540
+ unsubSync();
27029
27541
  root?.remove();
27030
27542
  };
27031
27543
  }
@@ -27228,9 +27740,9 @@ function renderConfigSection(body, config, refresh) {
27228
27740
  numField("git.debounceMs", "自动 commit 防抖(ms)", typeof git.debounceMs === "number" ? git.debounceMs : 6e4);
27229
27741
  textField("git.remote", "git 远端(空=仅本地)", typeof git.remote === "string" ? git.remote : "");
27230
27742
  textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
27231
- checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
27232
- checkField("ui.showPanelStatus", "显示 TW 面板右下角「状态/重载」悬浮按钮", ui.showPanelStatus !== false);
27233
- checkField("ui.showSyncButton", "显示右下角「同步」悬浮按钮", ui.showSyncButton !== false);
27743
+ checkField("ui.showQuickNote", "显示「知识库」按钮里的「快速笔记」入口", ui.showQuickNote !== false);
27744
+ checkField("ui.showPanelStatus", "显示「知识库」按钮里的 TW 面板/重载入口与状态行", ui.showPanelStatus !== false);
27745
+ checkField("ui.showSyncButton", "显示「知识库」按钮里的「同步」入口与 git 状态点", ui.showSyncButton !== false);
27234
27746
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
27235
27747
  save.type = "button";
27236
27748
  save.addEventListener("click", () => {
@@ -27464,10 +27976,13 @@ function apply(ctx) {
27464
27976
  const state = new PanelState();
27465
27977
  const disposers = [];
27466
27978
  try {
27979
+ const note = createNoteWidget();
27980
+ const sync = createSyncController();
27981
+ disposers.push(() => note.dispose());
27982
+ disposers.push(() => sync.dispose());
27467
27983
  disposers.push(mountSidebarEntry(state));
27468
27984
  disposers.push(mountPanel(state));
27469
- disposers.push(mountNoteWidget());
27470
- disposers.push(mountSyncButton());
27985
+ disposers.push(mountKnowledgeFab(state, note, sync));
27471
27986
  disposers.push(disposeEditorPopup);
27472
27987
  } catch (error) {
27473
27988
  console.error("[dsh-tiddlywiki] mount failed:", error);