dsh-tiddlywiki 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -88,9 +88,9 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
88
88
  setting controls whether they mount at all. */
89
89
  .dsh-tw-panel-status {
90
90
  /* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
91
- 垂直排在快速笔记按钮(bottom:88,高约 38px)上方;
91
+ 垂直排在「同步」按钮(bottom:140)上方;
92
92
  两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
93
- position: fixed; right: 24px; bottom: 140px; z-index: 950;
93
+ position: fixed; right: 24px; bottom: 192px; z-index: 950;
94
94
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
95
95
  }
96
96
  .dsh-tw-panel-status-btn {
@@ -125,9 +125,12 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
125
125
 
126
126
  /* ── floating quick-note widget ────────────────────────────────
127
127
  Positioned ABOVE the shutdown launcher FAB (fixed right:24 bottom:24,
128
- z-index 900, 46px) and BELOW its confirm overlay (z-index 1000). */
128
+ z-index 900, 46px) and BELOW its confirm overlay (z-index 1000).
129
+ Interactive overlays (the open card, the native-editor popup) sit ABOVE the
130
+ passive FAB column (sync/status, z-index 950) so those FABs never cover the
131
+ card's own bottom-right buttons. */
129
132
  .dsh-tw-note {
130
- position: fixed; right: 24px; bottom: 88px; z-index: 950;
133
+ position: fixed; right: 24px; bottom: 88px; z-index: 980;
131
134
  display: flex; flex-direction: column; align-items: flex-end; gap: 10px;
132
135
  font-family: inherit;
133
136
  }
@@ -138,11 +141,18 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
138
141
  border-radius: 14px; box-shadow: var(--dsw-shadow-lv3, 0 8px 30px rgba(0,0,0,.22));
139
142
  padding: 12px; display: flex; flex-direction: column; gap: 10px;
140
143
  font-size: 13px;
144
+ backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
145
+ animation: dsh-tw-note-in 180ms ease;
141
146
  }
142
147
  .dsh-tw-note-card[hidden] { display: none; }
148
+ @keyframes dsh-tw-note-in {
149
+ from { opacity: 0; transform: translateY(8px) scale(.98); }
150
+ to { opacity: 1; transform: translateY(0) scale(1); }
151
+ }
143
152
  .dsh-tw-note-head {
144
153
  display: flex; align-items: center; gap: 8px;
145
- font-size: 12px; font-weight: 600; color: var(--dsw-alias-label-primary, #222);
154
+ font-size: 12px; font-weight: 600; letter-spacing: .2px;
155
+ color: var(--dsw-alias-label-primary, #222);
146
156
  }
147
157
  .dsh-tw-note-head .dsh-tw-note-label { margin-right: auto; display: flex; align-items: center; gap: 6px; }
148
158
  .dsh-tw-note-close {
@@ -161,28 +171,91 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
161
171
  outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
162
172
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
163
173
  }
164
- .dsh-tw-note-text {
174
+ .dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
175
+ /* ── Markdown editor (highlighted-textarea overlay) ────────────────
176
+ The <pre> highlight layer and the transparent <textarea> share identical
177
+ metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
178
+ .dsh-tw-note-editor { position: relative; min-width: 0; }
179
+ .dsh-tw-note-editor pre,
180
+ .dsh-tw-note-editor textarea {
181
+ margin: 0; box-sizing: border-box; width: 100%;
165
182
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
166
- padding: 8px 9px; font: inherit; font-size: 13px; min-height: 120px; resize: vertical;
167
- background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
183
+ padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
184
+ white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
168
185
  transition: border-color 120ms ease, box-shadow 120ms ease;
169
186
  }
187
+ .dsh-tw-note-editor .dsh-tw-note-hl {
188
+ position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
189
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
190
+ pointer-events: none; overflow: hidden;
191
+ }
192
+ .dsh-tw-note-editor .dsh-tw-note-text {
193
+ position: relative; z-index: 1; resize: vertical;
194
+ background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
195
+ overflow: auto;
196
+ }
197
+ .dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
198
+ .dsh-tw-note-drop .dsh-tw-note-text,
199
+ .dsh-tw-note-drop .dsh-tw-note-hl {
200
+ border-color: var(--dsw-alias-brand-primary, #3e63dd);
201
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
202
+ }
203
+ /* ── markdown highlight tokens (colors only — never size/height, so the
204
+ overlay keeps line-for-line alignment with the invisible textarea) ── */
205
+ .dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
206
+ .dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
207
+ .dsh-tw-note-hl .md-code,
208
+ .dsh-tw-note-hl .md-code-block,
209
+ .dsh-tw-note-hl .md-fence {
210
+ font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
211
+ }
212
+ .dsh-tw-note-hl .md-code {
213
+ background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
214
+ border-radius: 4px; padding: 0 3px;
215
+ }
216
+ .dsh-tw-note-hl .md-code-block,
217
+ .dsh-tw-note-hl .md-fence {
218
+ display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
219
+ }
220
+ .dsh-tw-note-hl .md-bold { font-weight: 700; }
221
+ .dsh-tw-note-hl .md-italic { font-style: italic; }
222
+ .dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
223
+ .dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
224
+ .dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
225
+ .dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
226
+ .dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
227
+ .dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
228
+ .dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
229
+ .dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
230
+ .dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
231
+ .dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
170
232
  .dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
233
+ .dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
171
234
  .dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
235
+ .dsh-tw-note-upload {
236
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
237
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
238
+ font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
239
+ transition: filter 120ms ease, transform 80ms ease;
240
+ }
241
+ .dsh-tw-note-upload:hover { filter: brightness(.96); }
242
+ .dsh-tw-note-upload:active { transform: scale(.96); }
172
243
  .dsh-tw-note-save {
173
244
  border: 1px solid transparent; background: var(--dsw-alias-brand-primary, #3e63dd); color: #fff;
174
245
  font: inherit; font-size: 12px; padding: 6px 16px; border-radius: 8px; cursor: pointer;
175
- transition: filter 120ms ease;
246
+ transition: filter 120ms ease, transform 80ms ease;
176
247
  }
177
248
  .dsh-tw-note-save:hover:not(:disabled) { filter: brightness(1.08); }
249
+ .dsh-tw-note-save:active:not(:disabled) { transform: scale(.96); }
178
250
  .dsh-tw-note-save:disabled { opacity: .55; cursor: default; }
179
251
  .dsh-tw-note-edit {
180
252
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
181
253
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
182
254
  font: inherit; font-size: 12px; padding: 6px 10px; border-radius: 8px; cursor: pointer;
183
- transition: filter 120ms ease;
255
+ transition: filter 120ms ease, transform 80ms ease;
184
256
  }
185
257
  .dsh-tw-note-edit:hover:not(:disabled) { filter: brightness(.96); }
258
+ .dsh-tw-note-edit:active:not(:disabled) { transform: scale(.96); }
186
259
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
187
260
  /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
188
261
  .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
@@ -220,7 +293,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
220
293
  background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
221
294
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.2));
222
295
  border-radius: 12px; box-shadow: 0 14px 44px rgba(0,0,0,.3);
223
- z-index: 980; font-family: inherit; font-size: 13px;
296
+ z-index: 990; font-family: inherit; font-size: 13px;
224
297
  }
225
298
  .dsh-tw-editor-bar {
226
299
  display: flex; align-items: center; gap: 8px; flex: 0 0 auto;
@@ -246,6 +319,29 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
246
319
  }
247
320
  .dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
248
321
 
322
+ /* ── floating sync button (bottom-right) ───────────────────────
323
+ Pill FAB between the quick-note toggle (bottom:88) and the TW panel
324
+ status/reload floaters (bottom:192); dot reflects git state. */
325
+ .dsh-tw-sync {
326
+ position: fixed; right: 24px; bottom: 140px; z-index: 950;
327
+ display: inline-flex; align-items: center; gap: 7px;
328
+ padding: 9px 14px; border-radius: 999px;
329
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
330
+ background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
331
+ font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
332
+ cursor: pointer; transition: background-color 120ms ease;
333
+ }
334
+ .dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
335
+ .dsh-tw-sync:disabled { opacity: .65; cursor: default; }
336
+ .dsh-tw-sync-icon { display: inline-flex; flex: none; }
337
+ .dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
338
+ @keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
339
+ .dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
340
+ .dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
341
+ .dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
342
+ .dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
343
+ .dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
344
+
249
345
  /* ── toast ──────────────────────────────────────────────────── */
250
346
  .dsh-tw-toast {
251
347
  position: fixed; left: 50%; bottom: 96px; transform: translateX(-50%);
@@ -497,7 +593,7 @@ const PANEL_HOST_SELECTOR = "[data-dsh-panel-host]";
497
593
  const APP_OVERLAY_Z_INDEX = 20;
498
594
  /** Safety re-measure cadence for shell layout changes CSS can't see. */
499
595
  const SYNC_INTERVAL_MS = 2e3;
500
- const STATUS_ENDPOINT$1 = "/dsh-tiddlywiki/status";
596
+ const STATUS_ENDPOINT$2 = "/dsh-tiddlywiki/status";
501
597
  const RESTART_ENDPOINT$1 = "/dsh-tiddlywiki/restart";
502
598
  /**
503
599
  * The panel's z-index: below any dsh-better-sidebar host layer so its
@@ -520,9 +616,9 @@ function conversationColumn() {
520
616
  if (el !== null) return el;
521
617
  }
522
618
  }
523
- async function fetchStatus() {
619
+ async function fetchStatus$1() {
524
620
  try {
525
- const res = await fetch(STATUS_ENDPOINT$1, { signal: AbortSignal.timeout(8e3) });
621
+ const res = await fetch(STATUS_ENDPOINT$2, { signal: AbortSignal.timeout(8e3) });
526
622
  if (!res.ok) return null;
527
623
  return await res.json();
528
624
  } catch {
@@ -687,7 +783,7 @@ function mountPanel(state) {
687
783
  window.clearTimeout(refreshTimer);
688
784
  refreshTimer = void 0;
689
785
  }
690
- const payload = await fetchStatus();
786
+ const payload = await fetchStatus$1();
691
787
  if (payload === null) {
692
788
  setChip("failed", "状态不可达");
693
789
  showError("无法访问 /dsh-tiddlywiki/status");
@@ -760,7 +856,7 @@ function mountPanel(state) {
760
856
  ensure();
761
857
  applyActive();
762
858
  (async () => {
763
- if ((await fetchStatus())?.ui?.showPanelStatus === false) return;
859
+ if ((await fetchStatus$1())?.ui?.showPanelStatus === false) return;
764
860
  statusFloater = buildStatusFloater();
765
861
  })();
766
862
  return () => {
@@ -909,6 +1005,106 @@ function ensurePopup() {
909
1005
  });
910
1006
  }
911
1007
  //#endregion
1008
+ //#region src/client/markdown.ts
1009
+ /**
1010
+ * Lightweight Markdown syntax highlighter for the quick-note editor.
1011
+ *
1012
+ * The editor is the classic "highlighted textarea": a `<pre>` rendered BEHIND
1013
+ * a transparent-text `<textarea>` with identical metrics. To keep the two
1014
+ * layers perfectly aligned we NEVER change the user's text — every token is
1015
+ * only wrapped in a colored `<span class="md-*">`, so each line keeps its
1016
+ * exact characters and column widths. Leading whitespace (indentation) is
1017
+ * preserved as-is so list/heading/quote alignment survives.
1018
+ *
1019
+ * Coverage is deliberately pragmatic (a scratch-note editor, not CommonMark):
1020
+ * ATX headings, fenced code blocks, blockquotes, bullet + ordered + task
1021
+ * lists, horizontal rules, inline code, bold, italic, strikethrough, links
1022
+ * and images. HTML is escaped first, so the output is always safe to set as
1023
+ * `innerHTML` (no raw HTML from the user is ever executed).
1024
+ *
1025
+ * @module dsh-tiddlywiki/client/markdown
1026
+ */
1027
+ /** Escape a raw source fragment for safe insertion into the overlay HTML. */
1028
+ function escapeHtml(value) {
1029
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1030
+ }
1031
+ /**
1032
+ * Inline token highlighting on ALREADY-ESCAPED text. Inline code spans are
1033
+ * first pulled out into placeholders so later passes (links/bold/italic) can
1034
+ * never reach inside them, then restored.
1035
+ */
1036
+ function highlightInline(escaped) {
1037
+ const codeTokens = [];
1038
+ let s = escaped.replace(/(`+)([^`]+?)\1/g, (_all, t, code) => {
1039
+ codeTokens.push(`<span class="md-code">${t}${code}${t}</span>`);
1040
+ return `\u0000${codeTokens.length - 1}\u0000`;
1041
+ });
1042
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_a, alt, url) => `<span class="md-image">![${alt}](</span><span class="md-url">${url}</span><span class="md-image">)</span>`);
1043
+ s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_a, text, url) => `<span class="md-link">[${text}](</span><span class="md-url">${url}</span><span class="md-link">)</span>`);
1044
+ const boldTokens = [];
1045
+ s = s.replace(/(\*\*|__)([^*_]+?)\1/g, (_a, d, text) => {
1046
+ boldTokens.push(`<span class="md-bold">${d}${text}${d}</span>`);
1047
+ return `\u0001${boldTokens.length - 1}\u0001`;
1048
+ });
1049
+ s = s.replace(/(\*|_)([^*_]+?)\1/g, "<span class=\"md-italic\">$1$2$1</span>");
1050
+ s = s.replace(/~~([^~]+?)~~/g, "<span class=\"md-strike\">~~$1~~</span>");
1051
+ return s.replace(/\u0001(\d+)\u0001/g, (_a, i) => boldTokens[Number(i)] ?? "").replace(/\u0000(\d+)\u0000/g, (_a, i) => codeTokens[Number(i)] ?? "");
1052
+ }
1053
+ /**
1054
+ * Match and return groups as a plain string array ('' for absent groups), or
1055
+ * null when the pattern does not match. Avoids noUncheckedIndexedAccess
1056
+ * friction on RegExpMatchArray indexing.
1057
+ */
1058
+ function groups(re, value) {
1059
+ const m = value.match(re);
1060
+ if (m === null) return null;
1061
+ return Array.from(m, (g) => g ?? "");
1062
+ }
1063
+ /** Highlight one non-fence line (leading whitespace preserved verbatim). */
1064
+ function highlightLine(raw) {
1065
+ const leadMatch = groups(/^(\s*)(.*)$/, raw);
1066
+ const lead = leadMatch === null ? "" : leadMatch[1];
1067
+ const body = leadMatch === null ? raw : leadMatch[2];
1068
+ if (body.length === 0) return raw;
1069
+ const heading = groups(/^(#{1,6})(\s+)(.*)$/, body);
1070
+ if (heading !== null) return `${lead}<span class="md-heading">${escapeHtml(heading[1])}${escapeHtml(heading[2])}</span>${highlightInline(escapeHtml(heading[3]))}`;
1071
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(body)) return `${lead}<span class="md-hr">${escapeHtml(body)}</span>`;
1072
+ const quote = groups(/^(>)( ?)(.*)$/, body);
1073
+ if (quote !== null) return `${lead}<span class="md-quote">${escapeHtml(quote[1])}</span>${escapeHtml(quote[2])}${highlightInline(escapeHtml(quote[3]))}`;
1074
+ const task = groups(/^([-*+])(\s+)(\[[ xX]\])(\s+)(.*)$/, body);
1075
+ if (task !== null) {
1076
+ const checked = /\[[xX]\]/.test(task[3]);
1077
+ return `${lead}<span class="md-bullet">${escapeHtml(task[1])}</span>${escapeHtml(task[2])}<span class="md-task${checked ? " md-task-checked" : ""}">${escapeHtml(task[3])}</span>${escapeHtml(task[4])}${highlightInline(escapeHtml(task[5]))}`;
1078
+ }
1079
+ const bullet = groups(/^([-*+])(\s+)(.*)$/, body);
1080
+ if (bullet !== null) return `${lead}<span class="md-bullet">${escapeHtml(bullet[1])}</span>${escapeHtml(bullet[2])}${highlightInline(escapeHtml(bullet[3]))}`;
1081
+ const ordered = groups(/^(\d+)([.)])(\s+)(.*)$/, body);
1082
+ if (ordered !== null) return `${lead}<span class="md-number">${escapeHtml(ordered[1])}${escapeHtml(ordered[2])}</span>${escapeHtml(ordered[3])}${highlightInline(escapeHtml(ordered[4]))}`;
1083
+ return `${lead}${highlightInline(escapeHtml(body))}`;
1084
+ }
1085
+ /**
1086
+ * Highlight full Markdown source into safe `<span>`-wrapped HTML. Line count
1087
+ * and per-line text (including whitespace) are preserved exactly.
1088
+ */
1089
+ function highlightMarkdown(src) {
1090
+ const lines = src.split("\n");
1091
+ const out = [];
1092
+ let inFence = false;
1093
+ for (const rawLine of lines) {
1094
+ if (rawLine.match(/^\s*(```|~~~)\s*([\w-]*)\s*$/) !== null) {
1095
+ inFence = !inFence;
1096
+ out.push(`<span class="md-fence">${escapeHtml(rawLine)}</span>`);
1097
+ continue;
1098
+ }
1099
+ if (inFence) {
1100
+ out.push(`<span class="md-code-block">${escapeHtml(rawLine)}</span>`);
1101
+ continue;
1102
+ }
1103
+ out.push(highlightLine(rawLine));
1104
+ }
1105
+ return out.join("\n");
1106
+ }
1107
+ //#endregion
912
1108
  //#region src/client/note-widget.ts
913
1109
  /**
914
1110
  * Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
@@ -926,18 +1122,29 @@ function ensurePopup() {
926
1122
  * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
927
1123
  * an empty field removes the last chip; the dropdown filters on typing.
928
1124
  *
1125
+ * Markdown editing: a "highlighted textarea" overlay (a `<pre>` rendered
1126
+ * behind a transparent-text `<textarea>`, see markdown.ts) so Markdown is
1127
+ * syntax-highlighted as you type — no dependencies, perfect alignment.
1128
+ *
1129
+ * File upload: the body accepts files via the 📎 button or drag-and-drop;
1130
+ * each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
1131
+ * `files/` folder, git-tracked and served at `/files/<name>`) and a Markdown
1132
+ * image/link line is inserted at the caret.
1133
+ *
929
1134
  * @module dsh-tiddlywiki/client/note-widget
930
1135
  */
931
1136
  const NOTE_ENDPOINT = "/dsh-tiddlywiki/note";
932
1137
  const EDIT_ENDPOINT = "/dsh-tiddlywiki/edit";
933
- const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
1138
+ const STATUS_ENDPOINT$1 = "/dsh-tiddlywiki/status";
934
1139
  const TAGS_ENDPOINT = "/dsh-tiddlywiki/tags";
935
- function pad(n) {
1140
+ const UPLOAD_ENDPOINT = "/dsh-tiddlywiki/upload";
1141
+ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
1142
+ function pad$1(n) {
936
1143
  return n < 10 ? `0${n}` : String(n);
937
1144
  }
938
1145
  /** Default note title: `YYYY-MM-DD HH:mm`. */
939
1146
  function timestampTitle(date = /* @__PURE__ */ new Date()) {
940
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
1147
+ return `${date.getFullYear()}-${pad$1(date.getMonth() + 1)}-${pad$1(date.getDate())} ${pad$1(date.getHours())}:${pad$1(date.getMinutes())}`;
941
1148
  }
942
1149
  async function fetchUiOptions() {
943
1150
  const fallback = {
@@ -945,7 +1152,7 @@ async function fetchUiOptions() {
945
1152
  defaultTag: "inbox"
946
1153
  };
947
1154
  try {
948
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5e3) });
1155
+ const res = await fetch(STATUS_ENDPOINT$1, { signal: AbortSignal.timeout(5e3) });
949
1156
  if (!res.ok) return fallback;
950
1157
  const payload = await res.json();
951
1158
  return {
@@ -1068,6 +1275,81 @@ function buildTagEditor() {
1068
1275
  }
1069
1276
  };
1070
1277
  }
1278
+ /**
1279
+ * Markdown editor: a `<pre>` overlay (highlighted by markdown.ts) rendered
1280
+ * behind a transparent-text `<textarea>` with identical metrics. Scroll is
1281
+ * mirrored; on every input the overlay is re-rendered. The text layer stays
1282
+ * exactly aligned with the highlight layer because the highlighter never
1283
+ * changes the user's characters.
1284
+ */
1285
+ function buildEditor() {
1286
+ const wrap = document.createElement("div");
1287
+ wrap.className = "dsh-tw-note-editor";
1288
+ const hl = document.createElement("pre");
1289
+ hl.className = "dsh-tw-note-hl";
1290
+ hl.setAttribute("aria-hidden", "true");
1291
+ const textarea = document.createElement("textarea");
1292
+ textarea.className = "dsh-tw-note-text";
1293
+ textarea.placeholder = "写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存";
1294
+ wrap.append(hl, textarea);
1295
+ const render = () => {
1296
+ hl.innerHTML = `${highlightMarkdown(textarea.value)}\n`;
1297
+ hl.scrollTop = textarea.scrollTop;
1298
+ hl.scrollLeft = textarea.scrollLeft;
1299
+ };
1300
+ const syncScroll = () => {
1301
+ hl.scrollTop = textarea.scrollTop;
1302
+ hl.scrollLeft = textarea.scrollLeft;
1303
+ };
1304
+ textarea.addEventListener("input", render);
1305
+ textarea.addEventListener("scroll", syncScroll);
1306
+ render();
1307
+ return {
1308
+ el: wrap,
1309
+ textarea,
1310
+ render,
1311
+ focus: () => textarea.focus()
1312
+ };
1313
+ }
1314
+ /**
1315
+ * Upload one file to the wiki (raw body, name in ?name=), then insert a
1316
+ * Markdown image/link line at the caret of the given editor.
1317
+ */
1318
+ async function uploadInto(file, editor) {
1319
+ if (file.size > MAX_UPLOAD_BYTES) {
1320
+ toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
1321
+ return;
1322
+ }
1323
+ try {
1324
+ const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
1325
+ method: "POST",
1326
+ headers: { "content-type": file.type || "application/octet-stream" },
1327
+ body: file,
1328
+ signal: AbortSignal.timeout(12e4)
1329
+ });
1330
+ const payload = await res.json().catch(() => null);
1331
+ if (!res.ok || payload?.ok !== true) {
1332
+ toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`);
1333
+ return;
1334
+ }
1335
+ const name = payload.name ?? file.name;
1336
+ const markdown = file.type.startsWith("image/") ? `![${name}](${payload.url})` : `[${name}](${payload.url})`;
1337
+ const ta = editor.textarea;
1338
+ const start = ta.selectionStart ?? ta.value.length;
1339
+ const end = ta.selectionEnd ?? start;
1340
+ const before = ta.value.slice(0, start);
1341
+ const after = ta.value.slice(end);
1342
+ const insert = `${start > 0 && !before.endsWith("\n") ? "\n" : ""}${markdown}\n`;
1343
+ ta.value = `${before}${insert}${after}`;
1344
+ const caret = (before + insert).length;
1345
+ ta.setSelectionRange(caret, caret);
1346
+ ta.focus();
1347
+ editor.render();
1348
+ toast(`已上传「${name}」并插入链接`);
1349
+ } catch (err) {
1350
+ toast(`上传失败:${err instanceof Error ? err.message : String(err)}`);
1351
+ }
1352
+ }
1071
1353
  /** Build the whole widget DOM and wire it up (returns the appended root). */
1072
1354
  function buildWidget() {
1073
1355
  const root = document.createElement("div");
@@ -1093,14 +1375,56 @@ function buildWidget() {
1093
1375
  titleInput.placeholder = "标题(默认时间戳)";
1094
1376
  const tagEditor = buildTagEditor();
1095
1377
  fields.append(titleInput, tagEditor.el);
1096
- const textarea = document.createElement("textarea");
1097
- textarea.className = "dsh-tw-note-text";
1098
- textarea.placeholder = "写点东西…(Ctrl+Enter 保存)";
1378
+ const editor = buildEditor();
1379
+ const textarea = editor.textarea;
1380
+ const uploadBtn = document.createElement("button");
1381
+ uploadBtn.type = "button";
1382
+ uploadBtn.className = "dsh-tw-note-upload";
1383
+ uploadBtn.title = "上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)";
1384
+ uploadBtn.textContent = "📎 上传";
1385
+ const fileInput = document.createElement("input");
1386
+ fileInput.type = "file";
1387
+ fileInput.multiple = true;
1388
+ fileInput.hidden = true;
1389
+ uploadBtn.addEventListener("click", () => {
1390
+ fileInput.click();
1391
+ });
1392
+ fileInput.addEventListener("change", () => {
1393
+ for (const file of Array.from(fileInput.files ?? [])) uploadInto(file, editor);
1394
+ fileInput.value = "";
1395
+ });
1396
+ let dragDepth = 0;
1397
+ editor.el.addEventListener("dragenter", (event) => {
1398
+ event.preventDefault();
1399
+ dragDepth++;
1400
+ editor.el.classList.add("dsh-tw-note-drop");
1401
+ });
1402
+ editor.el.addEventListener("dragover", (event) => {
1403
+ event.preventDefault();
1404
+ });
1405
+ editor.el.addEventListener("dragleave", (event) => {
1406
+ event.preventDefault();
1407
+ dragDepth = Math.max(0, dragDepth - 1);
1408
+ if (dragDepth === 0) editor.el.classList.remove("dsh-tw-note-drop");
1409
+ });
1410
+ editor.el.addEventListener("drop", (event) => {
1411
+ event.preventDefault();
1412
+ dragDepth = 0;
1413
+ editor.el.classList.remove("dsh-tw-note-drop");
1414
+ const files = event.dataTransfer?.files;
1415
+ if (files === void 0 || files.length === 0) return;
1416
+ for (const file of Array.from(files)) uploadInto(file, editor);
1417
+ });
1099
1418
  const foot = document.createElement("div");
1100
1419
  foot.className = "dsh-tw-note-foot";
1420
+ const footLeft = document.createElement("div");
1421
+ footLeft.className = "dsh-tw-note-foot-left";
1101
1422
  const hint = document.createElement("span");
1102
1423
  hint.className = "dsh-tw-note-hint";
1103
1424
  hint.textContent = "Ctrl+Enter";
1425
+ footLeft.append(uploadBtn, hint);
1426
+ const footRight = document.createElement("div");
1427
+ footRight.className = "dsh-tw-note-foot-right";
1104
1428
  const edit = document.createElement("button");
1105
1429
  edit.type = "button";
1106
1430
  edit.className = "dsh-tw-note-edit";
@@ -1110,8 +1434,9 @@ function buildWidget() {
1110
1434
  save.type = "button";
1111
1435
  save.className = "dsh-tw-note-save";
1112
1436
  save.textContent = "保存";
1113
- foot.append(hint, edit, save);
1114
- card.append(head, fields, textarea, foot);
1437
+ footRight.append(edit, save);
1438
+ foot.append(footLeft, footRight);
1439
+ card.append(head, fields, editor.el, foot);
1115
1440
  const toggle = document.createElement("button");
1116
1441
  toggle.type = "button";
1117
1442
  toggle.className = "dsh-tw-note-toggle";
@@ -1127,7 +1452,7 @@ function buildWidget() {
1127
1452
  card.hidden = false;
1128
1453
  opened = true;
1129
1454
  resetTitle();
1130
- textarea.focus();
1455
+ editor.focus();
1131
1456
  defaultTag = (await fetchUiOptions()).defaultTag;
1132
1457
  tagEditor.setDefault(defaultTag);
1133
1458
  };
@@ -1164,6 +1489,7 @@ function buildWidget() {
1164
1489
  return;
1165
1490
  }
1166
1491
  textarea.value = "";
1492
+ editor.render();
1167
1493
  resetTitle();
1168
1494
  toast(`已保存「${payload.title ?? titleInput.value}」`);
1169
1495
  close();
@@ -1245,6 +1571,161 @@ function mountNoteWidget() {
1245
1571
  return disposer;
1246
1572
  }
1247
1573
  //#endregion
1574
+ //#region src/client/sync-button.ts
1575
+ /**
1576
+ * Floating "sync" button (bottom-right) — one click pulls + commits + pushes
1577
+ * the wiki git repository and reports the result, with a live status dot so
1578
+ * the human always sees whether the knowledge base is in sync.
1579
+ *
1580
+ * - honors `ui.showSyncButton`: the button is mounted async and never created
1581
+ * when the option is off (settings page toggle);
1582
+ * - polls /dsh-tiddlywiki/status every 30s for the git state;
1583
+ * - clicking POSTs /dsh-tiddlywiki/sync (pull → commit → push) and toasts the
1584
+ * outcome, then refreshes the dot immediately.
1585
+ *
1586
+ * Status dot mapping (git summary from /status):
1587
+ * offline → gray (no repo / service unreachable)
1588
+ * dirty → amber (uncommitted changes)
1589
+ * behind → red (remote has commits we don't — pull will rebase)
1590
+ * clean → green (worktree clean)
1591
+ *
1592
+ * @module dsh-tiddlywiki/client/sync-button
1593
+ */
1594
+ const STATUS_ENDPOINT = "/dsh-tiddlywiki/status";
1595
+ const SYNC_ENDPOINT$1 = "/dsh-tiddlywiki/sync";
1596
+ const POLL_MS = 3e4;
1597
+ /** Inline sync icon (circular arrows, stroke-based, theme-colored). */
1598
+ 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>";
1599
+ function pad(n) {
1600
+ return n < 10 ? `0${n}` : String(n);
1601
+ }
1602
+ /** Compact local time for the tooltip (e.g. "10:32"). */
1603
+ function clock(date) {
1604
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
1605
+ }
1606
+ async function fetchStatus() {
1607
+ try {
1608
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8e3) });
1609
+ if (!res.ok) return null;
1610
+ return await res.json();
1611
+ } catch {
1612
+ return null;
1613
+ }
1614
+ }
1615
+ /**
1616
+ * Mount the floating sync button. Fetches /status first: when
1617
+ * `ui.showSyncButton` is off the button is never created. Returns a disposer.
1618
+ */
1619
+ function mountSyncButton() {
1620
+ let disposed = false;
1621
+ let root;
1622
+ let dot;
1623
+ let label;
1624
+ let lastSync;
1625
+ let timer;
1626
+ const build = () => {
1627
+ const btn = document.createElement("button");
1628
+ btn.type = "button";
1629
+ btn.className = "dsh-tw-sync";
1630
+ btn.title = "同步知识库(pull → commit → push)";
1631
+ const icon = document.createElement("span");
1632
+ icon.className = "dsh-tw-sync-icon";
1633
+ icon.innerHTML = SYNC_ICON;
1634
+ dot = document.createElement("span");
1635
+ dot.className = "dsh-tw-sync-dot";
1636
+ dot.dataset.state = "offline";
1637
+ label = document.createElement("span");
1638
+ label.className = "dsh-tw-sync-label";
1639
+ label.textContent = "同步";
1640
+ btn.append(icon, label, dot);
1641
+ btn.addEventListener("click", () => {
1642
+ doSync();
1643
+ });
1644
+ document.body.append(btn);
1645
+ return btn;
1646
+ };
1647
+ /** Map a git summary onto the dot + label + tooltip. */
1648
+ const applyStatus = (payload) => {
1649
+ if (root === void 0 || dot === void 0 || label === void 0) return;
1650
+ const git = payload?.git;
1651
+ const bits = ["同步知识库"];
1652
+ let state = "offline";
1653
+ let word = "离线";
1654
+ if (git === null || git === void 0 || git.exists !== true) bits.push("git 仓库不可用");
1655
+ else {
1656
+ bits.push(`分支 ${git.branch ?? "?"}`);
1657
+ if (typeof git.lastCommit === "string") bits.push(git.lastCommit);
1658
+ const behind = typeof git.behind === "number" ? git.behind : 0;
1659
+ const ahead = typeof git.ahead === "number" ? git.ahead : 0;
1660
+ if (ahead > 0) bits.push(`领先 ${ahead}`);
1661
+ if (behind > 0) bits.push(`落后 ${behind}`);
1662
+ if (git.dirty === true) {
1663
+ state = "dirty";
1664
+ word = "待提交";
1665
+ bits.push(`有 ${git.dirtyFiles?.length ?? 0} 个未提交改动`);
1666
+ } else if (behind > 0) {
1667
+ state = "behind";
1668
+ word = "可更新";
1669
+ } else {
1670
+ state = "clean";
1671
+ word = "已同步";
1672
+ }
1673
+ }
1674
+ if (lastSync !== void 0) bits.push(`上次同步 ${clock(lastSync)}`);
1675
+ root.title = bits.join(" · ");
1676
+ dot.dataset.state = state;
1677
+ label.textContent = word;
1678
+ };
1679
+ const doSync = async () => {
1680
+ if (root === void 0) return;
1681
+ root.disabled = true;
1682
+ root.classList.add("dsh-tw-sync-spin");
1683
+ if (label !== void 0) label.textContent = "同步中…";
1684
+ if (dot !== void 0) dot.dataset.state = "syncing";
1685
+ try {
1686
+ const res = await fetch(SYNC_ENDPOINT$1, {
1687
+ method: "POST",
1688
+ signal: AbortSignal.timeout(12e4)
1689
+ });
1690
+ const payload = await res.json().catch(() => null);
1691
+ lastSync = /* @__PURE__ */ new Date();
1692
+ if (payload === null || payload.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`);
1693
+ else {
1694
+ let detail = "";
1695
+ if (payload.push && payload.push !== "nothing to commit") detail = `(${payload.push})`;
1696
+ if (payload.changed === true) {
1697
+ detail += payload.restarted === true ? ",TW 已重启" : ",TW 未自动重启";
1698
+ if (payload.restartError) detail += `(${payload.restartError})`;
1699
+ }
1700
+ toast(`同步完成:${payload.message ?? "OK"}${detail}`);
1701
+ }
1702
+ applyStatus(await fetchStatus());
1703
+ } catch (err) {
1704
+ toast(`同步失败:${err instanceof Error ? err.message : String(err)}`);
1705
+ } finally {
1706
+ root.disabled = false;
1707
+ root.classList.remove("dsh-tw-sync-spin");
1708
+ }
1709
+ };
1710
+ const poll = async () => {
1711
+ applyStatus(await fetchStatus());
1712
+ };
1713
+ (async () => {
1714
+ const payload = await fetchStatus();
1715
+ if (disposed || payload?.ui?.showSyncButton === false) return;
1716
+ root = build();
1717
+ applyStatus(payload);
1718
+ timer = window.setInterval(() => {
1719
+ poll();
1720
+ }, POLL_MS);
1721
+ })();
1722
+ return () => {
1723
+ disposed = true;
1724
+ if (timer !== void 0) window.clearInterval(timer);
1725
+ root?.remove();
1726
+ };
1727
+ }
1728
+ //#endregion
1248
1729
  //#region src/client/settings-page.ts
1249
1730
  /**
1250
1731
  * Settings-page half (design doc §13, config panel): a pure-DOM page mounted
@@ -1268,6 +1749,7 @@ const STATE_ENDPOINT = "/dsh-tiddlywiki/admin/state";
1268
1749
  const INFO_ENDPOINT = "/dsh-tiddlywiki/admin/info";
1269
1750
  const CONFIG_ENDPOINT = "/dsh-tiddlywiki/admin/config";
1270
1751
  const RESTART_ENDPOINT = "/dsh-tiddlywiki/admin/restart";
1752
+ const SYNC_ENDPOINT = "/dsh-tiddlywiki/sync";
1271
1753
  function make(tag, className, text) {
1272
1754
  const node = document.createElement(tag);
1273
1755
  if (className !== void 0) node.className = className;
@@ -1334,6 +1816,37 @@ function renderStatus(row, state, refresh) {
1334
1816
  state.git?.lastCommit !== void 0 ? state.git.lastCommit : "",
1335
1817
  state.git?.dirty === true ? "有未提交改动" : ""
1336
1818
  ].filter(Boolean).join(" · "));
1819
+ const sync = make("button", "dsh-tw-settings-btn", "同步");
1820
+ sync.type = "button";
1821
+ sync.title = "git 同步(pull → commit → push)";
1822
+ sync.addEventListener("click", () => {
1823
+ sync.disabled = true;
1824
+ sync.textContent = "同步中…";
1825
+ (async () => {
1826
+ try {
1827
+ const res = await fetch(SYNC_ENDPOINT, {
1828
+ method: "POST",
1829
+ signal: AbortSignal.timeout(12e4)
1830
+ });
1831
+ const payload = await res.json().catch(() => null);
1832
+ if (!res.ok || payload?.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`);
1833
+ else {
1834
+ let detail = "";
1835
+ if (payload.changed === true) {
1836
+ detail += payload.restarted === true ? ",TW 已重启" : ",TW 未自动重启";
1837
+ if (payload.restartError) detail += `(${payload.restartError})`;
1838
+ }
1839
+ toast(`同步完成:${payload.message ?? "OK"}${detail}`);
1840
+ }
1841
+ } catch (err) {
1842
+ toast(`同步失败:${err instanceof Error ? err.message : String(err)}`);
1843
+ } finally {
1844
+ sync.disabled = false;
1845
+ sync.textContent = "同步";
1846
+ refresh();
1847
+ }
1848
+ })();
1849
+ });
1337
1850
  const restart = make("button", "dsh-tw-settings-btn", "重启 TW");
1338
1851
  restart.type = "button";
1339
1852
  restart.addEventListener("click", () => {
@@ -1352,7 +1865,7 @@ function renderStatus(row, state, refresh) {
1352
1865
  }
1353
1866
  })();
1354
1867
  });
1355
- row.append(chip, label, restart);
1868
+ row.append(chip, label, sync, restart);
1356
1869
  }
1357
1870
  /** Config section: fields bound to effective config, changed-only save. */
1358
1871
  function renderConfigSection(body, config, refresh) {
@@ -1413,6 +1926,7 @@ function renderConfigSection(body, config, refresh) {
1413
1926
  textField("git.branch", "git 分支", typeof git.branch === "string" ? git.branch : "main");
1414
1927
  checkField("ui.showQuickNote", "显示「快速笔记」悬浮按钮", ui.showQuickNote !== false);
1415
1928
  checkField("ui.showPanelStatus", "显示 TW 面板右下角「状态/重载」悬浮按钮", ui.showPanelStatus !== false);
1929
+ checkField("ui.showSyncButton", "显示右下角「同步」悬浮按钮", ui.showSyncButton !== false);
1416
1930
  const save = make("button", "dsh-tw-settings-btn dsh-tw-settings-primary", "保存配置");
1417
1931
  save.type = "button";
1418
1932
  save.addEventListener("click", () => {
@@ -1649,6 +2163,7 @@ function apply(ctx) {
1649
2163
  disposers.push(mountSidebarEntry(state));
1650
2164
  disposers.push(mountPanel(state));
1651
2165
  disposers.push(mountNoteWidget());
2166
+ disposers.push(mountSyncButton());
1652
2167
  disposers.push(disposeEditorPopup);
1653
2168
  } catch (error) {
1654
2169
  console.error("[dsh-tiddlywiki] mount failed:", error);