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