dsh-tiddlywiki 0.1.2 → 0.2.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/README.md CHANGED
@@ -24,7 +24,7 @@ TiddlyWiki 的特性正好合适:单文件、纯文本、wiki 语法、自带
24
24
  |---|---|
25
25
  | Agent 工具 | `tiddlywiki_search` / `get` / `put` / `delete` / `git_sync` |
26
26
  | 人编辑 | 侧边栏「TiddlyWiki」入口 → 中央列内嵌完整 TW 编辑器(iframe 直连 TW 服务) |
27
- | 快速笔记 | 聊天区右下角常驻悬浮控件(可折叠,标题/tag 可自定义,Ctrl+Enter 保存) |
27
+ | 快速笔记 | 聊天区右下角常驻悬浮控件(可折叠,可多选/自动补全 tagCtrl+Enter 保存;设置页可整体隐藏) |
28
28
  | 零摩擦生命周期 | 随 dsh 自动启动/关闭;TW 子进程崩溃自动重启(退避);端口/目录/首次 git init 全自动 |
29
29
  | 同步 | 单线程交替模型:开工 pull,收工 commit+push,自动 commit(60s 防抖,可关) |
30
30
 
@@ -43,13 +43,13 @@ dsh plugin --profile web add github:bbqisbbq/dsh-tiddlywiki
43
43
  dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
44
44
  ```
45
45
 
46
- 首次启动会向 wiki **幂等写入**一篇「dsh-tiddlywiki 插件说明」笔记(tag `docs`)。
46
+ 首次启动会向 wiki **写入一次**「dsh-tiddlywiki 插件说明」笔记(tag `docs`),并默认安装「侧边移到左侧.css」界面补丁(把 TW 右侧栏移到左边;被删除后,只要设置页「侧边栏移左 CSS」开关开着就会随启动补丁还原)。
47
47
 
48
48
  ## 发布 / 仓库
49
49
 
50
50
  - **GitHub(公开)**:https://github.com/bbqisbbq/dsh-tiddlywiki
51
51
  - **npm**:`dsh-tiddlywiki`(`npm i dsh-tiddlywiki`;https://www.npmjs.com/package/dsh-tiddlywiki )
52
- - **说明笔记**:插件在首次启动时向 wiki **幂等写入**一篇「`dsh-tiddlywiki 插件说明`」笔记(tag `docs`)——不存在才写、手动编辑不被覆盖、删除后重启会重建;当前 live wiki 里已有一份。
52
+ - **说明笔记**:插件在**首次启动**时向 wiki **写入一次**「`dsh-tiddlywiki 插件说明`」笔记(tag `docs`)——只写一次、手动编辑不被覆盖、**删除后重启不会自动恢复**(一次性标记;清空 wiki 重装会再写入)。当前 live wiki 里已有一份。
53
53
 
54
54
  ## 可被检索的标准字段
55
55
 
@@ -85,6 +85,9 @@ dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
85
85
  branch: "main"
86
86
  note:
87
87
  tag: "inbox"
88
+ ui:
89
+ showQuickNote: true # 是否显示右下角「快速笔记」按钮
90
+ sidebarLeftCss: true # 是否启用「侧边移到左侧.css」补丁(被删后随启动还原)
88
91
  auth:
89
92
  username: "" # 默认 loopback 匿名;暴露到非 loopback 时才需要
90
93
  password: ""
@@ -95,7 +98,7 @@ dsh plugin --profile web add link:C:\Users\bbq\.dsh\plugins\dsh-tiddlywiki
95
98
 
96
99
  > 运行时配置:设置页(见「设置页」)写入的 `$:/plugins/dsh-tiddlywiki/config`
97
100
  > tiddler 是 `config:` 块之上的覆盖层(tiddler 优先、随 wiki 的 git 同步)。
98
- > 无需改动 cordis 也能改 note tag / git 开关等。
101
+ > 无需改动 cordis 也能改 note tag / git 开关 / ui 开关等。
99
102
 
100
103
  ## 使用
101
104
 
@@ -48,6 +48,21 @@ const CSS_TEXT = `
48
48
  .dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
49
49
  .dsh-tw-entry svg { flex: none; }
50
50
  .dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
51
+ /* Collapsed rail: the shell narrows the sidebar to a 56px icon rail (dual
52
+ signals — the frame's data-sidebar-collapsed + the sidebar root's hashed
53
+ *_collapsed class, same doctrine as dsh-taskboard 0.4.3). Mirror the
54
+ native 36×36 icon-button geometry: center the icon, hide the label, and
55
+ scale it up to match the shell's 18px rail icons. */
56
+ [data-sidebar-collapsed] [data-dsh-tw-entry],
57
+ [class*="_collapsed"] [data-dsh-tw-entry] {
58
+ width: 36px; height: 36px; min-width: 36px;
59
+ margin: 0 0 12px; padding: 0;
60
+ justify-content: center; gap: 0; text-align: center;
61
+ }
62
+ [data-sidebar-collapsed] [data-dsh-tw-entry] .dsh-tw-entry-label,
63
+ [class*="_collapsed"] [data-dsh-tw-entry] .dsh-tw-entry-label { display: none; }
64
+ [data-sidebar-collapsed] [data-dsh-tw-entry] svg,
65
+ [class*="_collapsed"] [data-dsh-tw-entry] svg { width: 18px; height: 18px; }
51
66
 
52
67
  /* ── center-column panel (fixed overlay, JS-pinned to the column rect) ── */
53
68
  .dsh-tw-view {
@@ -163,6 +178,34 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
163
178
  }
164
179
  .dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
165
180
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
181
+ /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
182
+ .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
183
+ .dsh-tw-note-chips { display: flex; flex-wrap: wrap; gap: 4px; }
184
+ .dsh-tw-note-tagchip {
185
+ display: inline-flex; align-items: center; gap: 4px;
186
+ padding: 2px 6px 2px 8px; border-radius: 999px; font-size: 11px; line-height: 1.4;
187
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 10%, var(--dsw-alias-bg-layer-1, #fff));
188
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 30%, transparent);
189
+ color: var(--dsw-alias-label-primary, #222);
190
+ }
191
+ .dsh-tw-note-tagchip-x {
192
+ cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; border-radius: 50%;
193
+ color: var(--dsw-alias-label-secondary, #888);
194
+ }
195
+ .dsh-tw-note-tagchip-x:hover { color: var(--dsw-alias-state-error-primary, #d13b3b); }
196
+ .dsh-tw-note-taginput { width: 100%; box-sizing: border-box; }
197
+ .dsh-tw-note-tagsuggest {
198
+ position: absolute; left: 0; right: 0; top: calc(100% + 2px); z-index: 60;
199
+ max-height: 168px; overflow-y: auto; border-radius: 8px; padding: 4px;
200
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
201
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
202
+ box-shadow: var(--dsw-shadow-lv2, 0 4px 16px rgba(0,0,0,.16));
203
+ font-size: 12px;
204
+ }
205
+ .dsh-tw-note-tagsuggest-item {
206
+ padding: 6px 9px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
207
+ }
208
+ .dsh-tw-note-tagsuggest-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
166
209
  /* ── Native-editor popup iframe (quick-note "在 TW 中编辑") ──────────────── */
167
210
  .dsh-tw-editor-popup {
168
211
  position: fixed; left: 0; right: 0; top: 0; bottom: 0; margin: auto;
@@ -847,14 +890,23 @@ function ensurePopup() {
847
890
  * collapsible, independent of any shell DOM. Lets the human jot drafts /
848
891
  * scratch notes while waiting for the AI or drafting the next prompt.
849
892
  *
850
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
893
+ * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
851
894
  * editable; defaults: timestamp title + config tag, usually "inbox").
852
895
  *
896
+ * ui.showQuickNote config: the widget is mounted async and stays hidden (and
897
+ * never appended) when the option is off, so the toggle button can be disabled
898
+ * from the settings page without touching the shell.
899
+ *
900
+ * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
901
+ * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
902
+ * an empty field removes the last chip; the dropdown filters on typing.
903
+ *
853
904
  * @module dsh-tiddlywiki/client/note-widget
854
905
  */
855
906
  const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
856
907
  const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
857
908
  const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
909
+ const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
858
910
  function pad(n) {
859
911
  return n < 10 ? `0${n}` : String(n);
860
912
  }
@@ -862,16 +914,137 @@ function pad(n) {
862
914
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
863
915
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
864
916
  }
865
- async function fetchDefaultTag() {
917
+ async function fetchUiOptions() {
918
+ const fallback = {
919
+ showQuickNote: true,
920
+ defaultTag: "inbox"
921
+ };
866
922
  try {
867
923
  const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
868
- if (!res.ok) return "inbox";
869
- return (await res.json()).note?.tag ?? "inbox";
924
+ if (!res.ok) return fallback;
925
+ const payload = await res.json();
926
+ return {
927
+ showQuickNote: payload.ui?.showQuickNote !== false,
928
+ defaultTag: payload.note?.tag ?? "inbox"
929
+ };
870
930
  } catch {
871
- return "inbox";
931
+ return fallback;
872
932
  }
873
933
  }
874
- function mountNoteWidget() {
934
+ /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
935
+ function buildTagEditor() {
936
+ const wrap = document.createElement("div");
937
+ wrap.className = "dsh-tw-note-tags";
938
+ const chipWrap = document.createElement("div");
939
+ chipWrap.className = "dsh-tw-note-chips";
940
+ const input = document.createElement("input");
941
+ input.className = "dsh-tw-note-taginput";
942
+ input.placeholder = "tag(可多选,自动补全)";
943
+ const suggest = document.createElement("div");
944
+ suggest.className = "dsh-tw-note-tagsuggest";
945
+ suggest.hidden = true;
946
+ wrap.append(chipWrap, input, suggest);
947
+ const chips = [];
948
+ const hideSuggest = () => {
949
+ suggest.hidden = true;
950
+ };
951
+ const renderChips = () => {
952
+ chipWrap.replaceChildren();
953
+ for (const tag of chips) {
954
+ const chip = document.createElement("span");
955
+ chip.className = "dsh-tw-note-tagchip";
956
+ chip.textContent = tag;
957
+ const x = document.createElement("span");
958
+ x.className = "dsh-tw-note-tagchip-x";
959
+ x.textContent = "×";
960
+ x.title = `移除 tag「${tag}」`;
961
+ x.addEventListener("click", (event) => {
962
+ event.stopPropagation();
963
+ const i = chips.indexOf(tag);
964
+ if (i >= 0) {
965
+ chips.splice(i, 1);
966
+ renderChips();
967
+ }
968
+ });
969
+ chip.append(x);
970
+ chipWrap.append(chip);
971
+ }
972
+ };
973
+ const addTag = (tag) => {
974
+ const t = tag.trim();
975
+ if (t.length === 0 || chips.includes(t)) return;
976
+ chips.push(t);
977
+ input.value = "";
978
+ renderChips();
979
+ hideSuggest();
980
+ input.focus();
981
+ };
982
+ const commitInput = () => {
983
+ for (const raw of input.value.split(/\s+/)) addTag(raw);
984
+ };
985
+ let knownTags = [];
986
+ let tagsPromise;
987
+ const ensureTags = () => {
988
+ tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5e3) }).then((r) => r.ok ? r.json() : Promise.resolve({})).then((p) => [...p.tags ?? []].sort((a, b) => a.localeCompare(b, "zh"))).catch(() => []);
989
+ tagsPromise.then((list) => {
990
+ knownTags = list;
991
+ });
992
+ return tagsPromise;
993
+ };
994
+ const showSuggest = () => {
995
+ const q = input.value.trim().toLowerCase();
996
+ const matches = knownTags.filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q))).slice(0, 8);
997
+ suggest.replaceChildren();
998
+ for (const tag of matches) {
999
+ const item = document.createElement("div");
1000
+ item.className = "dsh-tw-note-tagsuggest-item";
1001
+ item.textContent = tag;
1002
+ item.addEventListener("mousedown", (event) => {
1003
+ event.preventDefault();
1004
+ addTag(tag);
1005
+ });
1006
+ suggest.append(item);
1007
+ }
1008
+ suggest.hidden = matches.length === 0;
1009
+ };
1010
+ input.addEventListener("focus", () => {
1011
+ ensureTags().then(showSuggest);
1012
+ });
1013
+ input.addEventListener("input", () => {
1014
+ if (knownTags.length === 0) ensureTags().then(showSuggest);
1015
+ else showSuggest();
1016
+ });
1017
+ input.addEventListener("keydown", (event) => {
1018
+ if (event.key === "Enter" || event.key === ",") {
1019
+ event.preventDefault();
1020
+ commitInput();
1021
+ } else if (event.key === "Backspace" && input.value.length === 0 && chips.length > 0) {
1022
+ chips.pop();
1023
+ renderChips();
1024
+ } else if (event.key === "Escape") hideSuggest();
1025
+ });
1026
+ document.addEventListener("click", (event) => {
1027
+ if (!wrap.contains(event.target)) hideSuggest();
1028
+ }, true);
1029
+ return {
1030
+ el: wrap,
1031
+ getTags: () => {
1032
+ commitInput();
1033
+ return [...chips];
1034
+ },
1035
+ setDefault: (tag) => {
1036
+ if (chips.length === 0) {
1037
+ const t = tag.trim();
1038
+ if (t.length > 0) {
1039
+ chips.push(t);
1040
+ renderChips();
1041
+ }
1042
+ }
1043
+ }
1044
+ };
1045
+ }
1046
+ /** Build the whole widget DOM and wire it up (returns the appended root). */
1047
+ function buildWidget() {
875
1048
  const root = document.createElement("div");
876
1049
  root.className = "dsh-tw-note";
877
1050
  const card = document.createElement("div");
@@ -893,10 +1066,8 @@ function mountNoteWidget() {
893
1066
  const titleInput = document.createElement("input");
894
1067
  titleInput.className = "dsh-tw-note-title";
895
1068
  titleInput.placeholder = "标题(默认时间戳)";
896
- const tagInput = document.createElement("input");
897
- tagInput.className = "dsh-tw-note-tag";
898
- tagInput.placeholder = "tag(默认 inbox)";
899
- fields.append(titleInput, tagInput);
1069
+ const tagEditor = buildTagEditor();
1070
+ fields.append(titleInput, tagEditor.el);
900
1071
  const textarea = document.createElement("textarea");
901
1072
  textarea.className = "dsh-tw-note-text";
902
1073
  textarea.placeholder = "写点东西…(Ctrl+Enter 保存)";
@@ -931,10 +1102,9 @@ function mountNoteWidget() {
931
1102
  card.hidden = false;
932
1103
  opened = true;
933
1104
  resetTitle();
934
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
935
1105
  textarea.focus();
936
- defaultTag = await fetchDefaultTag();
937
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
1106
+ defaultTag = (await fetchUiOptions()).defaultTag;
1107
+ tagEditor.setDefault(defaultTag);
938
1108
  };
939
1109
  const close = () => {
940
1110
  card.hidden = true;
@@ -958,7 +1128,7 @@ function mountNoteWidget() {
958
1128
  headers: { "content-type": "application/json" },
959
1129
  body: JSON.stringify({
960
1130
  title: titleInput.value.trim(),
961
- tag: tagInput.value.trim(),
1131
+ tags: tagEditor.getTags(),
962
1132
  text
963
1133
  }),
964
1134
  signal: AbortSignal.timeout(1e4)
@@ -992,7 +1162,7 @@ function mountNoteWidget() {
992
1162
  const doEdit = async () => {
993
1163
  const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle();
994
1164
  const text = textarea.value;
995
- const tag = tagInput.value.trim();
1165
+ const tags = tagEditor.getTags();
996
1166
  edit.disabled = true;
997
1167
  edit.textContent = "打开中…";
998
1168
  try {
@@ -1001,7 +1171,7 @@ function mountNoteWidget() {
1001
1171
  headers: { "content-type": "application/json" },
1002
1172
  body: JSON.stringify({
1003
1173
  title,
1004
- tag,
1174
+ tags,
1005
1175
  text
1006
1176
  }),
1007
1177
  signal: AbortSignal.timeout(1e4)
@@ -1027,10 +1197,27 @@ function mountNoteWidget() {
1027
1197
  edit.addEventListener("click", () => {
1028
1198
  doEdit();
1029
1199
  });
1030
- return () => {
1031
- root.remove();
1200
+ return root;
1201
+ }
1202
+ /**
1203
+ * Mount the floating quick-note widget. Fetches /status first: when
1204
+ * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
1205
+ * Returns a disposer that removes it.
1206
+ */
1207
+ function mountNoteWidget() {
1208
+ let disposed = false;
1209
+ let root;
1210
+ const disposer = () => {
1211
+ disposed = true;
1212
+ root?.remove();
1032
1213
  document.querySelector(".dsh-tw-toast")?.remove();
1033
1214
  };
1215
+ (async () => {
1216
+ const ui = await fetchUiOptions();
1217
+ if (disposed || !ui.showQuickNote) return;
1218
+ root = buildWidget();
1219
+ })();
1220
+ return disposer;
1034
1221
  }
1035
1222
  //#endregion
1036
1223
  //#region src/client/settings-page.ts
@@ -1148,6 +1335,7 @@ function renderConfigSection(body, config, refresh) {
1148
1335
  section.append(make("h3", "dsh-tw-settings-h", "常规配置"));
1149
1336
  const note = config.note ?? {};
1150
1337
  const git = config.git ?? {};
1338
+ const ui = config.ui ?? {};
1151
1339
  const fields = [];
1152
1340
  const textField = (key, label, initial) => {
1153
1341
  const input = make("input", "dsh-tw-settings-input");
@@ -1198,6 +1386,8 @@ function renderConfigSection(body, config, refresh) {
1198
1386
  numField("git.debounceMs", "自动 commit 防抖(ms)", typeof git.debounceMs === "number" ? git.debounceMs : 6e4);
1199
1387
  textField("git.remote", "git 远端(空=仅本地)", typeof git.remote === "string" ? git.remote : "");
1200
1388
  textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
1389
+ checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
1390
+ checkField("ui.sidebarLeftCss", "侧边栏移左 CSS(随启动补丁还原被删除的样式)", ui.sidebarLeftCss !== false);
1201
1391
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
1202
1392
  save.type = "button";
1203
1393
  save.addEventListener("click", () => {
package/lib/client.js CHANGED
@@ -54,6 +54,21 @@ const CSS_TEXT = `
54
54
  .dsh-tw-entry[data-active="true"] { background: var(--dsw-alias-interactive-bg-active, rgba(128,128,128,.18)); color: var(--dsw-alias-label-primary, inherit); font-weight: 500; }
55
55
  .dsh-tw-entry svg { flex: none; }
56
56
  .dsh-tw-entry .dsh-tw-entry-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
57
+ /* Collapsed rail: the shell narrows the sidebar to a 56px icon rail (dual
58
+ signals — the frame's data-sidebar-collapsed + the sidebar root's hashed
59
+ *_collapsed class, same doctrine as dsh-taskboard 0.4.3). Mirror the
60
+ native 36×36 icon-button geometry: center the icon, hide the label, and
61
+ scale it up to match the shell's 18px rail icons. */
62
+ [data-sidebar-collapsed] [data-dsh-tw-entry],
63
+ [class*="_collapsed"] [data-dsh-tw-entry] {
64
+ width: 36px; height: 36px; min-width: 36px;
65
+ margin: 0 0 12px; padding: 0;
66
+ justify-content: center; gap: 0; text-align: center;
67
+ }
68
+ [data-sidebar-collapsed] [data-dsh-tw-entry] .dsh-tw-entry-label,
69
+ [class*="_collapsed"] [data-dsh-tw-entry] .dsh-tw-entry-label { display: none; }
70
+ [data-sidebar-collapsed] [data-dsh-tw-entry] svg,
71
+ [class*="_collapsed"] [data-dsh-tw-entry] svg { width: 18px; height: 18px; }
57
72
 
58
73
  /* ── center-column panel (fixed overlay, JS-pinned to the column rect) ── */
59
74
  .dsh-tw-view {
@@ -169,6 +184,34 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
169
184
  }
170
185
  .dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
171
186
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
187
+ /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
188
+ .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
189
+ .dsh-tw-note-chips { display: flex; flex-wrap: wrap; gap: 4px; }
190
+ .dsh-tw-note-tagchip {
191
+ display: inline-flex; align-items: center; gap: 4px;
192
+ padding: 2px 6px 2px 8px; border-radius: 999px; font-size: 11px; line-height: 1.4;
193
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 10%, var(--dsw-alias-bg-layer-1, #fff));
194
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 30%, transparent);
195
+ color: var(--dsw-alias-label-primary, #222);
196
+ }
197
+ .dsh-tw-note-tagchip-x {
198
+ cursor: pointer; font-size: 12px; line-height: 1; padding: 0 2px; border-radius: 50%;
199
+ color: var(--dsw-alias-label-secondary, #888);
200
+ }
201
+ .dsh-tw-note-tagchip-x:hover { color: var(--dsw-alias-state-error-primary, #d13b3b); }
202
+ .dsh-tw-note-taginput { width: 100%; box-sizing: border-box; }
203
+ .dsh-tw-note-tagsuggest {
204
+ position: absolute; left: 0; right: 0; top: calc(100% + 2px); z-index: 60;
205
+ max-height: 168px; overflow-y: auto; border-radius: 8px; padding: 4px;
206
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
207
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
208
+ box-shadow: var(--dsw-shadow-lv2, 0 4px 16px rgba(0,0,0,.16));
209
+ font-size: 12px;
210
+ }
211
+ .dsh-tw-note-tagsuggest-item {
212
+ padding: 6px 9px; border-radius: 6px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
213
+ }
214
+ .dsh-tw-note-tagsuggest-item:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
172
215
  /* ── Native-editor popup iframe (quick-note "在 TW 中编辑") ──────────────── */
173
216
  .dsh-tw-editor-popup {
174
217
  position: fixed; left: 0; right: 0; top: 0; bottom: 0; margin: auto;
@@ -853,14 +896,23 @@ function ensurePopup() {
853
896
  * collapsible, independent of any shell DOM. Lets the human jot drafts /
854
897
  * scratch notes while waiting for the AI or drafting the next prompt.
855
898
  *
856
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
899
+ * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
857
900
  * editable; defaults: timestamp title + config tag, usually "inbox").
858
901
  *
902
+ * ui.showQuickNote config: the widget is mounted async and stays hidden (and
903
+ * never appended) when the option is off, so the toggle button can be disabled
904
+ * from the settings page without touching the shell.
905
+ *
906
+ * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
907
+ * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
908
+ * an empty field removes the last chip; the dropdown filters on typing.
909
+ *
859
910
  * @module dsh-tiddlywiki/client/note-widget
860
911
  */
861
912
  const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
862
913
  const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
863
914
  const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
915
+ const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
864
916
  function pad(n) {
865
917
  return n < 10 ? `0${n}` : String(n);
866
918
  }
@@ -868,16 +920,137 @@ function pad(n) {
868
920
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
869
921
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
870
922
  }
871
- async function fetchDefaultTag() {
923
+ async function fetchUiOptions() {
924
+ const fallback = {
925
+ showQuickNote: true,
926
+ defaultTag: "inbox"
927
+ };
872
928
  try {
873
929
  const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
874
- if (!res.ok) return "inbox";
875
- return (await res.json()).note?.tag ?? "inbox";
930
+ if (!res.ok) return fallback;
931
+ const payload = await res.json();
932
+ return {
933
+ showQuickNote: payload.ui?.showQuickNote !== false,
934
+ defaultTag: payload.note?.tag ?? "inbox"
935
+ };
876
936
  } catch {
877
- return "inbox";
937
+ return fallback;
878
938
  }
879
939
  }
880
- function mountNoteWidget() {
940
+ /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
941
+ function buildTagEditor() {
942
+ const wrap = document.createElement("div");
943
+ wrap.className = "dsh-tw-note-tags";
944
+ const chipWrap = document.createElement("div");
945
+ chipWrap.className = "dsh-tw-note-chips";
946
+ const input = document.createElement("input");
947
+ input.className = "dsh-tw-note-taginput";
948
+ input.placeholder = "tag(可多选,自动补全)";
949
+ const suggest = document.createElement("div");
950
+ suggest.className = "dsh-tw-note-tagsuggest";
951
+ suggest.hidden = true;
952
+ wrap.append(chipWrap, input, suggest);
953
+ const chips = [];
954
+ const hideSuggest = () => {
955
+ suggest.hidden = true;
956
+ };
957
+ const renderChips = () => {
958
+ chipWrap.replaceChildren();
959
+ for (const tag of chips) {
960
+ const chip = document.createElement("span");
961
+ chip.className = "dsh-tw-note-tagchip";
962
+ chip.textContent = tag;
963
+ const x = document.createElement("span");
964
+ x.className = "dsh-tw-note-tagchip-x";
965
+ x.textContent = "×";
966
+ x.title = `移除 tag「${tag}」`;
967
+ x.addEventListener("click", (event) => {
968
+ event.stopPropagation();
969
+ const i = chips.indexOf(tag);
970
+ if (i >= 0) {
971
+ chips.splice(i, 1);
972
+ renderChips();
973
+ }
974
+ });
975
+ chip.append(x);
976
+ chipWrap.append(chip);
977
+ }
978
+ };
979
+ const addTag = (tag) => {
980
+ const t = tag.trim();
981
+ if (t.length === 0 || chips.includes(t)) return;
982
+ chips.push(t);
983
+ input.value = "";
984
+ renderChips();
985
+ hideSuggest();
986
+ input.focus();
987
+ };
988
+ const commitInput = () => {
989
+ for (const raw of input.value.split(/\s+/)) addTag(raw);
990
+ };
991
+ let knownTags = [];
992
+ let tagsPromise;
993
+ const ensureTags = () => {
994
+ tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5e3) }).then((r) => r.ok ? r.json() : Promise.resolve({})).then((p) => [...p.tags ?? []].sort((a, b) => a.localeCompare(b, "zh"))).catch(() => []);
995
+ tagsPromise.then((list) => {
996
+ knownTags = list;
997
+ });
998
+ return tagsPromise;
999
+ };
1000
+ const showSuggest = () => {
1001
+ const q = input.value.trim().toLowerCase();
1002
+ const matches = knownTags.filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q))).slice(0, 8);
1003
+ suggest.replaceChildren();
1004
+ for (const tag of matches) {
1005
+ const item = document.createElement("div");
1006
+ item.className = "dsh-tw-note-tagsuggest-item";
1007
+ item.textContent = tag;
1008
+ item.addEventListener("mousedown", (event) => {
1009
+ event.preventDefault();
1010
+ addTag(tag);
1011
+ });
1012
+ suggest.append(item);
1013
+ }
1014
+ suggest.hidden = matches.length === 0;
1015
+ };
1016
+ input.addEventListener("focus", () => {
1017
+ ensureTags().then(showSuggest);
1018
+ });
1019
+ input.addEventListener("input", () => {
1020
+ if (knownTags.length === 0) ensureTags().then(showSuggest);
1021
+ else showSuggest();
1022
+ });
1023
+ input.addEventListener("keydown", (event) => {
1024
+ if (event.key === "Enter" || event.key === ",") {
1025
+ event.preventDefault();
1026
+ commitInput();
1027
+ } else if (event.key === "Backspace" && input.value.length === 0 && chips.length > 0) {
1028
+ chips.pop();
1029
+ renderChips();
1030
+ } else if (event.key === "Escape") hideSuggest();
1031
+ });
1032
+ document.addEventListener("click", (event) => {
1033
+ if (!wrap.contains(event.target)) hideSuggest();
1034
+ }, true);
1035
+ return {
1036
+ el: wrap,
1037
+ getTags: () => {
1038
+ commitInput();
1039
+ return [...chips];
1040
+ },
1041
+ setDefault: (tag) => {
1042
+ if (chips.length === 0) {
1043
+ const t = tag.trim();
1044
+ if (t.length > 0) {
1045
+ chips.push(t);
1046
+ renderChips();
1047
+ }
1048
+ }
1049
+ }
1050
+ };
1051
+ }
1052
+ /** Build the whole widget DOM and wire it up (returns the appended root). */
1053
+ function buildWidget() {
881
1054
  const root = document.createElement("div");
882
1055
  root.className = "dsh-tw-note";
883
1056
  const card = document.createElement("div");
@@ -899,10 +1072,8 @@ function mountNoteWidget() {
899
1072
  const titleInput = document.createElement("input");
900
1073
  titleInput.className = "dsh-tw-note-title";
901
1074
  titleInput.placeholder = "标题(默认时间戳)";
902
- const tagInput = document.createElement("input");
903
- tagInput.className = "dsh-tw-note-tag";
904
- tagInput.placeholder = "tag(默认 inbox)";
905
- fields.append(titleInput, tagInput);
1075
+ const tagEditor = buildTagEditor();
1076
+ fields.append(titleInput, tagEditor.el);
906
1077
  const textarea = document.createElement("textarea");
907
1078
  textarea.className = "dsh-tw-note-text";
908
1079
  textarea.placeholder = "写点东西…(Ctrl+Enter 保存)";
@@ -937,10 +1108,9 @@ function mountNoteWidget() {
937
1108
  card.hidden = false;
938
1109
  opened = true;
939
1110
  resetTitle();
940
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
941
1111
  textarea.focus();
942
- defaultTag = await fetchDefaultTag();
943
- if (tagInput.value.length === 0) tagInput.value = defaultTag;
1112
+ defaultTag = (await fetchUiOptions()).defaultTag;
1113
+ tagEditor.setDefault(defaultTag);
944
1114
  };
945
1115
  const close = () => {
946
1116
  card.hidden = true;
@@ -964,7 +1134,7 @@ function mountNoteWidget() {
964
1134
  headers: { "content-type": "application/json" },
965
1135
  body: JSON.stringify({
966
1136
  title: titleInput.value.trim(),
967
- tag: tagInput.value.trim(),
1137
+ tags: tagEditor.getTags(),
968
1138
  text
969
1139
  }),
970
1140
  signal: AbortSignal.timeout(1e4)
@@ -998,7 +1168,7 @@ function mountNoteWidget() {
998
1168
  const doEdit = async () => {
999
1169
  const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle();
1000
1170
  const text = textarea.value;
1001
- const tag = tagInput.value.trim();
1171
+ const tags = tagEditor.getTags();
1002
1172
  edit.disabled = true;
1003
1173
  edit.textContent = "打开中…";
1004
1174
  try {
@@ -1007,7 +1177,7 @@ function mountNoteWidget() {
1007
1177
  headers: { "content-type": "application/json" },
1008
1178
  body: JSON.stringify({
1009
1179
  title,
1010
- tag,
1180
+ tags,
1011
1181
  text
1012
1182
  }),
1013
1183
  signal: AbortSignal.timeout(1e4)
@@ -1033,10 +1203,27 @@ function mountNoteWidget() {
1033
1203
  edit.addEventListener("click", () => {
1034
1204
  doEdit();
1035
1205
  });
1036
- return () => {
1037
- root.remove();
1206
+ return root;
1207
+ }
1208
+ /**
1209
+ * Mount the floating quick-note widget. Fetches /status first: when
1210
+ * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
1211
+ * Returns a disposer that removes it.
1212
+ */
1213
+ function mountNoteWidget() {
1214
+ let disposed = false;
1215
+ let root;
1216
+ const disposer = () => {
1217
+ disposed = true;
1218
+ root?.remove();
1038
1219
  document.querySelector(".dsh-tw-toast")?.remove();
1039
1220
  };
1221
+ (async () => {
1222
+ const ui = await fetchUiOptions();
1223
+ if (disposed || !ui.showQuickNote) return;
1224
+ root = buildWidget();
1225
+ })();
1226
+ return disposer;
1040
1227
  }
1041
1228
  //#endregion
1042
1229
  //#region src/client/settings-page.ts
@@ -1154,6 +1341,7 @@ function renderConfigSection(body, config, refresh) {
1154
1341
  section.append(make("h3", "dsh-tw-settings-h", "常规配置"));
1155
1342
  const note = config.note ?? {};
1156
1343
  const git = config.git ?? {};
1344
+ const ui = config.ui ?? {};
1157
1345
  const fields = [];
1158
1346
  const textField = (key, label, initial) => {
1159
1347
  const input = make("input", "dsh-tw-settings-input");
@@ -1204,6 +1392,8 @@ function renderConfigSection(body, config, refresh) {
1204
1392
  numField("git.debounceMs", "自动 commit 防抖(ms)", typeof git.debounceMs === "number" ? git.debounceMs : 6e4);
1205
1393
  textField("git.remote", "git 远端(空=仅本地)", typeof git.remote === "string" ? git.remote : "");
1206
1394
  textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
1395
+ checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
1396
+ checkField("ui.sidebarLeftCss", "侧边栏移左 CSS(随启动补丁还原被删除的样式)", ui.sidebarLeftCss !== false);
1207
1397
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
1208
1398
  save.type = "button";
1209
1399
  save.addEventListener("click", () => {