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