dsh-tiddlywiki 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -25
- package/lib/client.bundle.js +903 -386
- package/lib/client.js +903 -386
- package/lib/index.js +796 -32
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/index.ts +10 -4
- package/src/client/knowledge-fab.ts +232 -0
- package/src/client/markdown-editor.ts +5 -0
- package/src/client/note-widget.ts +725 -475
- package/src/client/panel.ts +27 -62
- package/src/client/settings-page.ts +3 -3
- package/src/client/styles.ts +110 -0
- package/src/client/sync-button.ts +191 -188
- package/src/host/git.ts +21 -0
- package/src/host/routes.ts +145 -8
- package/src/host/seed-notes.ts +7 -5
- package/src/host/tools.ts +589 -281
- package/src/host/tw-api.ts +80 -6
- package/src/host/wiki.ts +12 -0
- package/src/index.ts +46 -6
- package/src/sdk.ts +8 -2
|
@@ -1,475 +1,725 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Floating quick-note
|
|
3
|
-
* collapsible
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
} {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
.
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
save.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Floating quick-note card (design doc §12, D6/D7) — a bottom-right, fixed,
|
|
3
|
+
* collapsible card for jotting drafts / scratch notes while waiting for the AI
|
|
4
|
+
* or drafting the next prompt. In v0.5 the trigger moved into the "知识库" FAB
|
|
5
|
+
* (knowledge-fab.ts); this module ONLY owns the card itself via
|
|
6
|
+
* `createNoteWidget()` (lazy build — no DOM until first open).
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - CodeMirror 6 Markdown editor (see markdown-editor.ts), file upload + drag
|
|
10
|
+
* & drop, multi-tag chips with autocomplete (GET /dsh-tiddlywiki/tags).
|
|
11
|
+
* - DRAFT PERSISTENCE: editor/title/tags auto-save to localStorage on a 500ms
|
|
12
|
+
* debounce; reopening restores the draft with a "丢弃" banner. A successful
|
|
13
|
+
* save / "在 TW 中编辑" clears it.
|
|
14
|
+
* - RECENT PICKER: a 「最近」 button lists the newest notes (GET
|
|
15
|
+
* /dsh-tiddlywiki/recent); clicking one loads it into the editor (GET
|
|
16
|
+
* /dsh-tiddlywiki/get) to continue editing.
|
|
17
|
+
* - Ctrl+Enter saves; 「✏️ 在 TW 中编辑」 saves and opens TW's native editor.
|
|
18
|
+
*
|
|
19
|
+
* Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
|
|
20
|
+
* editable; defaults: timestamp title + config tag, usually "inbox").
|
|
21
|
+
*
|
|
22
|
+
* @module dsh-tiddlywiki/client/note-widget
|
|
23
|
+
*/
|
|
24
|
+
import { toast } from './toast.ts'
|
|
25
|
+
import { openEditorPopup } from './editor-popup.ts'
|
|
26
|
+
import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
|
|
27
|
+
|
|
28
|
+
const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
|
|
29
|
+
const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
|
|
30
|
+
const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
|
|
31
|
+
const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
|
|
32
|
+
const RECENT_ENDPOINT = '/dsh-tiddlywiki/recent'
|
|
33
|
+
const GET_ENDPOINT = '/dsh-tiddlywiki/get'
|
|
34
|
+
const UPLOAD_ENDPOINT = '/dsh-tiddlywiki/upload'
|
|
35
|
+
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
36
|
+
|
|
37
|
+
/** localStorage key for the autosaved quick-note draft. */
|
|
38
|
+
const DRAFT_KEY = 'dsh-tw-note-draft-v1'
|
|
39
|
+
/** Draft auto-save debounce. */
|
|
40
|
+
const DRAFT_DEBOUNCE_MS = 500
|
|
41
|
+
|
|
42
|
+
interface Draft { text: string; title: string; tags: string[]; savedAt: number }
|
|
43
|
+
|
|
44
|
+
function loadDraft(): Draft | null {
|
|
45
|
+
try {
|
|
46
|
+
const raw = localStorage.getItem(DRAFT_KEY)
|
|
47
|
+
if (raw === null) return null
|
|
48
|
+
const parsed = JSON.parse(raw) as Partial<Draft>
|
|
49
|
+
if (typeof parsed.text !== 'string' || typeof parsed.title !== 'string') return null
|
|
50
|
+
return {
|
|
51
|
+
text: parsed.text,
|
|
52
|
+
title: parsed.title,
|
|
53
|
+
tags: Array.isArray(parsed.tags) ? parsed.tags.filter((t): t is string => typeof t === 'string') : [],
|
|
54
|
+
savedAt: typeof parsed.savedAt === 'number' ? parsed.savedAt : 0,
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function persistDraft(draft: Draft): void {
|
|
62
|
+
try { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)) } catch { /* storage unavailable */ }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearDraft(): void {
|
|
66
|
+
try { localStorage.removeItem(DRAFT_KEY) } catch { /* ignore */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function pad(n: number): string {
|
|
70
|
+
return n < 10 ? `0${n}` : String(n)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Default note title: `YYYY-MM-DD HH:mm`. */
|
|
74
|
+
function timestampTitle(date = new Date()): string {
|
|
75
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Compact Chinese relative time for the recent picker (e.g. "3小时前"). */
|
|
79
|
+
function relativeTime(iso: string | null): string {
|
|
80
|
+
if (iso === null) return ''
|
|
81
|
+
const ms = new Date(iso).getTime()
|
|
82
|
+
if (Number.isNaN(ms)) return ''
|
|
83
|
+
const diff = Date.now() - ms
|
|
84
|
+
const min = Math.floor(diff / 60_000)
|
|
85
|
+
if (min < 1) return '刚刚'
|
|
86
|
+
if (min < 60) return `${min}分钟前`
|
|
87
|
+
const hr = Math.floor(min / 60)
|
|
88
|
+
if (hr < 24) return `${hr}小时前`
|
|
89
|
+
const day = Math.floor(hr / 24)
|
|
90
|
+
if (day < 30) return `${day}天前`
|
|
91
|
+
return new Date(ms).toLocaleDateString('zh-CN')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface UiOptions { showQuickNote: boolean; defaultTag: string }
|
|
95
|
+
|
|
96
|
+
async function fetchUiOptions(): Promise<UiOptions> {
|
|
97
|
+
const fallback: UiOptions = { showQuickNote: true, defaultTag: 'inbox' }
|
|
98
|
+
try {
|
|
99
|
+
const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
|
|
100
|
+
if (!res.ok) return fallback
|
|
101
|
+
const payload = (await res.json()) as { ui?: { showQuickNote?: boolean }; note?: { tag?: string } }
|
|
102
|
+
return {
|
|
103
|
+
showQuickNote: payload.ui?.showQuickNote !== false,
|
|
104
|
+
defaultTag: payload.note?.tag ?? 'inbox',
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
return fallback
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
|
|
112
|
+
function buildTagEditor(opts: { onChange?: () => void } = {}): {
|
|
113
|
+
el: HTMLDivElement
|
|
114
|
+
getTags: () => string[]
|
|
115
|
+
setDefault: (tag: string) => void
|
|
116
|
+
setTags: (tags: string[]) => void
|
|
117
|
+
} {
|
|
118
|
+
const wrap = document.createElement('div')
|
|
119
|
+
wrap.className = 'dsh-tw-note-tags'
|
|
120
|
+
|
|
121
|
+
const chipWrap = document.createElement('div')
|
|
122
|
+
chipWrap.className = 'dsh-tw-note-chips'
|
|
123
|
+
const input = document.createElement('input')
|
|
124
|
+
input.className = 'dsh-tw-note-taginput'
|
|
125
|
+
input.placeholder = 'tag(可多选,自动补全)'
|
|
126
|
+
const suggest = document.createElement('div')
|
|
127
|
+
suggest.className = 'dsh-tw-note-tagsuggest'
|
|
128
|
+
suggest.hidden = true
|
|
129
|
+
wrap.append(chipWrap, input, suggest)
|
|
130
|
+
|
|
131
|
+
const chips: string[] = []
|
|
132
|
+
const hideSuggest = (): void => { suggest.hidden = true }
|
|
133
|
+
|
|
134
|
+
const emit = (): void => { opts.onChange?.() }
|
|
135
|
+
|
|
136
|
+
const renderChips = (): void => {
|
|
137
|
+
chipWrap.replaceChildren()
|
|
138
|
+
for (const tag of chips) {
|
|
139
|
+
const chip = document.createElement('span')
|
|
140
|
+
chip.className = 'dsh-tw-note-tagchip'
|
|
141
|
+
chip.textContent = tag
|
|
142
|
+
const x = document.createElement('span')
|
|
143
|
+
x.className = 'dsh-tw-note-tagchip-x'
|
|
144
|
+
x.textContent = '×'
|
|
145
|
+
x.title = `移除 tag「${tag}」`
|
|
146
|
+
x.addEventListener('click', (event) => {
|
|
147
|
+
event.stopPropagation()
|
|
148
|
+
const i = chips.indexOf(tag)
|
|
149
|
+
if (i >= 0) {
|
|
150
|
+
chips.splice(i, 1)
|
|
151
|
+
renderChips()
|
|
152
|
+
emit()
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
chip.append(x)
|
|
156
|
+
chipWrap.append(chip)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const addTag = (tag: string): void => {
|
|
161
|
+
const t = tag.trim()
|
|
162
|
+
if (t.length === 0 || chips.includes(t)) return
|
|
163
|
+
chips.push(t)
|
|
164
|
+
input.value = ''
|
|
165
|
+
renderChips()
|
|
166
|
+
hideSuggest()
|
|
167
|
+
input.focus()
|
|
168
|
+
emit()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const commitInput = (): void => {
|
|
172
|
+
for (const raw of input.value.split(/\s+/)) addTag(raw)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Existing tags, fetched lazily once per widget lifetime.
|
|
176
|
+
let knownTags: string[] = []
|
|
177
|
+
let tagsPromise: Promise<string[]> | undefined
|
|
178
|
+
const ensureTags = (): Promise<string[]> => {
|
|
179
|
+
tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
|
|
180
|
+
.then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
|
|
181
|
+
.then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
|
|
182
|
+
.catch(() => [])
|
|
183
|
+
void tagsPromise.then((list) => { knownTags = list })
|
|
184
|
+
return tagsPromise
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const showSuggest = (): void => {
|
|
188
|
+
const q = input.value.trim().toLowerCase()
|
|
189
|
+
const matches = knownTags
|
|
190
|
+
.filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
|
|
191
|
+
.slice(0, 8)
|
|
192
|
+
suggest.replaceChildren()
|
|
193
|
+
for (const tag of matches) {
|
|
194
|
+
const item = document.createElement('div')
|
|
195
|
+
item.className = 'dsh-tw-note-tagsuggest-item'
|
|
196
|
+
item.textContent = tag
|
|
197
|
+
item.addEventListener('mousedown', (event) => {
|
|
198
|
+
event.preventDefault()
|
|
199
|
+
addTag(tag)
|
|
200
|
+
})
|
|
201
|
+
suggest.append(item)
|
|
202
|
+
}
|
|
203
|
+
suggest.hidden = matches.length === 0
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
|
|
207
|
+
input.addEventListener('input', () => {
|
|
208
|
+
if (knownTags.length === 0) void ensureTags().then(showSuggest)
|
|
209
|
+
else showSuggest()
|
|
210
|
+
})
|
|
211
|
+
input.addEventListener('keydown', (event) => {
|
|
212
|
+
if (event.key === 'Enter' || event.key === ',') {
|
|
213
|
+
event.preventDefault()
|
|
214
|
+
commitInput()
|
|
215
|
+
} else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
|
|
216
|
+
chips.pop()
|
|
217
|
+
renderChips()
|
|
218
|
+
emit()
|
|
219
|
+
} else if (event.key === 'Escape') {
|
|
220
|
+
hideSuggest()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
document.addEventListener('click', (event) => {
|
|
224
|
+
if (!wrap.contains(event.target as Node)) hideSuggest()
|
|
225
|
+
}, true)
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
el: wrap,
|
|
229
|
+
getTags: () => {
|
|
230
|
+
commitInput()
|
|
231
|
+
return [...chips]
|
|
232
|
+
},
|
|
233
|
+
setDefault: (tag: string) => {
|
|
234
|
+
// Only pre-fill when nothing is chosen yet; never steal focus.
|
|
235
|
+
if (chips.length === 0) {
|
|
236
|
+
const t = tag.trim()
|
|
237
|
+
if (t.length > 0) {
|
|
238
|
+
chips.push(t)
|
|
239
|
+
renderChips()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
setTags: (tags: string[]) => {
|
|
244
|
+
chips.length = 0
|
|
245
|
+
for (const tag of Array.isArray(tags) ? tags : []) {
|
|
246
|
+
const t = typeof tag === 'string' ? tag.trim() : ''
|
|
247
|
+
if (t.length > 0 && !chips.includes(t)) chips.push(t)
|
|
248
|
+
}
|
|
249
|
+
input.value = ''
|
|
250
|
+
renderChips()
|
|
251
|
+
hideSuggest()
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** All DOM refs created by build(); undefined until the first open. */
|
|
257
|
+
interface BuiltUi {
|
|
258
|
+
root: HTMLDivElement
|
|
259
|
+
card: HTMLDivElement
|
|
260
|
+
editor: MarkdownEditor
|
|
261
|
+
titleInput: HTMLInputElement
|
|
262
|
+
tagEditor: ReturnType<typeof buildTagEditor>
|
|
263
|
+
saveBtn: HTMLButtonElement
|
|
264
|
+
editBtn: HTMLButtonElement
|
|
265
|
+
draftBanner: HTMLDivElement
|
|
266
|
+
recentBtn: HTMLButtonElement
|
|
267
|
+
recentWrap: HTMLDivElement
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Upload one file to the wiki (raw body, name in ?name=), then insert a
|
|
272
|
+
* Markdown image/link line at the caret of the given editor.
|
|
273
|
+
*/
|
|
274
|
+
async function uploadInto(file: File, editor: MarkdownEditor): Promise<void> {
|
|
275
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
276
|
+
toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
try {
|
|
280
|
+
const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'content-type': file.type || 'application/octet-stream' },
|
|
283
|
+
body: file,
|
|
284
|
+
signal: AbortSignal.timeout(120_000),
|
|
285
|
+
})
|
|
286
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; name?: string; url?: string; error?: string } | null
|
|
287
|
+
if (!res.ok || payload?.ok !== true) {
|
|
288
|
+
toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
const name = payload.name ?? file.name
|
|
292
|
+
const markdown = file.type.startsWith('image/')
|
|
293
|
+
? ``
|
|
294
|
+
: `[${name}](${payload.url})`
|
|
295
|
+
editor.insertAtCaret(markdown)
|
|
296
|
+
toast(`已上传「${name}」并插入链接`)
|
|
297
|
+
} catch (err) {
|
|
298
|
+
toast(`上传失败:${err instanceof Error ? err.message : String(err)}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
interface RecentItem { title: string; tags: string[]; modified: string | null; snippet: string }
|
|
303
|
+
|
|
304
|
+
/** The quick-note card handle the FAB drives. */
|
|
305
|
+
export interface NoteWidgetHandle {
|
|
306
|
+
open(): Promise<void>
|
|
307
|
+
close(): void
|
|
308
|
+
toggle(): Promise<void>
|
|
309
|
+
isOpen(): boolean
|
|
310
|
+
dispose(): void
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Create the quick-note card. Lazy: no DOM is created until the first
|
|
315
|
+
* `open()`, so a hidden widget (`ui.showQuickNote` off, or never used) leaves
|
|
316
|
+
* no side effects. Returns a handle the "知识库" FAB controls.
|
|
317
|
+
*/
|
|
318
|
+
export function createNoteWidget(): NoteWidgetHandle {
|
|
319
|
+
// ── lazily-built state ──────────────────────────────────────────────────
|
|
320
|
+
let ui: BuiltUi | undefined
|
|
321
|
+
let disposed = false
|
|
322
|
+
let opened = false
|
|
323
|
+
let defaultTag = 'inbox'
|
|
324
|
+
let draftTimer: number | undefined
|
|
325
|
+
let recentOpen = false
|
|
326
|
+
|
|
327
|
+
const resetTitle = (): void => {
|
|
328
|
+
if (ui !== undefined) ui.titleInput.value = timestampTitle()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Debounced draft auto-save (500ms after the last change). */
|
|
332
|
+
const scheduleDraft = (): void => {
|
|
333
|
+
if (disposed || !opened || ui === undefined) return
|
|
334
|
+
if (draftTimer !== undefined) { clearTimeout(draftTimer); draftTimer = undefined }
|
|
335
|
+
draftTimer = window.setTimeout(() => {
|
|
336
|
+
draftTimer = undefined
|
|
337
|
+
if (ui === undefined) return
|
|
338
|
+
const text = ui.editor.getValue()
|
|
339
|
+
const title = ui.titleInput.value.trim()
|
|
340
|
+
if (text.trim().length === 0 && title.length === 0) {
|
|
341
|
+
clearDraft()
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
persistDraft({ text, title, tags: ui.tagEditor.getTags(), savedAt: Date.now() })
|
|
345
|
+
}, DRAFT_DEBOUNCE_MS)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const hideDraftBanner = (): void => {
|
|
349
|
+
if (ui !== undefined) ui.draftBanner.hidden = true
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const closeRecent = (): void => {
|
|
353
|
+
recentOpen = false
|
|
354
|
+
if (ui !== undefined) ui.recentWrap.hidden = true
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Load a tiddler into the editor (recent picker click). */
|
|
358
|
+
const loadNote = async (title: string): Promise<void> => {
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(10_000) })
|
|
361
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; text?: string; tags?: string[]; notFound?: boolean; error?: string } | null
|
|
362
|
+
if (!res.ok || payload?.ok !== true || typeof payload.title !== 'string') {
|
|
363
|
+
const reason = payload?.notFound === true ? '不存在' : (payload?.error ?? `HTTP ${res.status}`)
|
|
364
|
+
toast(`读取失败:${reason}`)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (ui === undefined) return
|
|
368
|
+
ui.editor.setValue(payload.text ?? '')
|
|
369
|
+
ui.titleInput.value = payload.title
|
|
370
|
+
ui.tagEditor.setTags(payload.tags ?? [])
|
|
371
|
+
hideDraftBanner()
|
|
372
|
+
closeRecent()
|
|
373
|
+
toast(`已载入「${payload.title}」`)
|
|
374
|
+
ui.editor.focus()
|
|
375
|
+
} catch (err) {
|
|
376
|
+
toast(`读取失败:${err instanceof Error ? err.message : String(err)}`)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Toggle the recent-notes dropdown (fetches lazily each open). */
|
|
381
|
+
const toggleRecent = (): void => {
|
|
382
|
+
if (ui === undefined) return
|
|
383
|
+
if (recentOpen) { closeRecent(); return }
|
|
384
|
+
recentOpen = true
|
|
385
|
+
ui.recentWrap.hidden = false
|
|
386
|
+
ui.recentWrap.textContent = ''
|
|
387
|
+
const loading = document.createElement('div')
|
|
388
|
+
loading.className = 'dsh-tw-note-recent-muted'
|
|
389
|
+
loading.textContent = '加载中…'
|
|
390
|
+
ui.recentWrap.append(loading)
|
|
391
|
+
void (async () => {
|
|
392
|
+
try {
|
|
393
|
+
const res = await fetch(`${RECENT_ENDPOINT}?limit=15`, { signal: AbortSignal.timeout(10_000) })
|
|
394
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; items?: RecentItem[]; error?: string } | null
|
|
395
|
+
if (!recentOpen || ui === undefined) return
|
|
396
|
+
ui.recentWrap.replaceChildren()
|
|
397
|
+
const items = payload?.ok === true ? (payload.items ?? []) : []
|
|
398
|
+
if (items.length === 0) {
|
|
399
|
+
const empty = document.createElement('div')
|
|
400
|
+
empty.className = 'dsh-tw-note-recent-muted'
|
|
401
|
+
empty.textContent = payload?.ok === true ? '暂无笔记' : `加载失败:${payload?.error ?? '未知'}`
|
|
402
|
+
ui.recentWrap.append(empty)
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
for (const item of items) {
|
|
406
|
+
const row = document.createElement('div')
|
|
407
|
+
row.className = 'dsh-tw-note-recent-item'
|
|
408
|
+
row.title = item.snippet || item.title
|
|
409
|
+
const name = document.createElement('span')
|
|
410
|
+
name.className = 'dsh-tw-note-recent-name'
|
|
411
|
+
name.textContent = item.title
|
|
412
|
+
const meta = document.createElement('span')
|
|
413
|
+
meta.className = 'dsh-tw-note-recent-meta'
|
|
414
|
+
const bits: string[] = [relativeTime(item.modified)]
|
|
415
|
+
if (item.tags.length > 0) bits.unshift(item.tags.slice(0, 3).join(','))
|
|
416
|
+
meta.textContent = bits.join(' · ')
|
|
417
|
+
row.append(name, meta)
|
|
418
|
+
row.addEventListener('click', () => { void loadNote(item.title) })
|
|
419
|
+
ui.recentWrap.append(row)
|
|
420
|
+
}
|
|
421
|
+
} catch (err) {
|
|
422
|
+
if (!recentOpen || ui === undefined) return
|
|
423
|
+
ui.recentWrap.replaceChildren()
|
|
424
|
+
const empty = document.createElement('div')
|
|
425
|
+
empty.className = 'dsh-tw-note-recent-muted'
|
|
426
|
+
empty.textContent = `加载失败:${err instanceof Error ? err.message : String(err)}`
|
|
427
|
+
ui.recentWrap.append(empty)
|
|
428
|
+
}
|
|
429
|
+
})()
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Build the whole card DOM once, then wire every interaction. */
|
|
433
|
+
const build = (): void => {
|
|
434
|
+
if (ui !== undefined) return
|
|
435
|
+
|
|
436
|
+
const root = document.createElement('div')
|
|
437
|
+
root.className = 'dsh-tw-note'
|
|
438
|
+
root.hidden = true
|
|
439
|
+
|
|
440
|
+
const card = document.createElement('div')
|
|
441
|
+
card.className = 'dsh-tw-note-card'
|
|
442
|
+
|
|
443
|
+
const head = document.createElement('div')
|
|
444
|
+
head.className = 'dsh-tw-note-head'
|
|
445
|
+
const label = document.createElement('span')
|
|
446
|
+
label.className = 'dsh-tw-note-label'
|
|
447
|
+
label.textContent = '📝 快速笔记'
|
|
448
|
+
const closeBtn = document.createElement('button')
|
|
449
|
+
closeBtn.type = 'button'
|
|
450
|
+
closeBtn.className = 'dsh-tw-note-close'
|
|
451
|
+
closeBtn.title = '收起'
|
|
452
|
+
closeBtn.textContent = '✕'
|
|
453
|
+
head.append(label, closeBtn)
|
|
454
|
+
|
|
455
|
+
// Restored-draft banner (hidden until a draft is restored).
|
|
456
|
+
const draftBanner = document.createElement('div')
|
|
457
|
+
draftBanner.className = 'dsh-tw-note-draft'
|
|
458
|
+
draftBanner.hidden = true
|
|
459
|
+
const bannerText = document.createElement('span')
|
|
460
|
+
bannerText.className = 'dsh-tw-note-draft-text'
|
|
461
|
+
bannerText.textContent = '已恢复未保存草稿'
|
|
462
|
+
const discardBtn = document.createElement('button')
|
|
463
|
+
discardBtn.type = 'button'
|
|
464
|
+
discardBtn.className = 'dsh-tw-note-draft-discard'
|
|
465
|
+
discardBtn.textContent = '丢弃'
|
|
466
|
+
draftBanner.append(bannerText, discardBtn)
|
|
467
|
+
|
|
468
|
+
const fields = document.createElement('div')
|
|
469
|
+
fields.className = 'dsh-tw-note-fields'
|
|
470
|
+
const titleInput = document.createElement('input')
|
|
471
|
+
titleInput.className = 'dsh-tw-note-title'
|
|
472
|
+
titleInput.placeholder = '标题(默认时间戳)'
|
|
473
|
+
const tagEditor = buildTagEditor({ onChange: scheduleDraft })
|
|
474
|
+
fields.append(titleInput, tagEditor.el)
|
|
475
|
+
titleInput.addEventListener('input', scheduleDraft)
|
|
476
|
+
|
|
477
|
+
// Mod-Enter save routes through doSave, which is assigned below (the editor
|
|
478
|
+
// keymap only runs after the widget is fully wired, so this is safe).
|
|
479
|
+
let doSave: (() => Promise<void>) | undefined
|
|
480
|
+
const editor = buildMarkdownEditor({
|
|
481
|
+
placeholder: '写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存',
|
|
482
|
+
onSave: () => { void doSave?.() },
|
|
483
|
+
onChange: scheduleDraft,
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
// ── file upload (button + drag & drop onto the editor) ────────────────
|
|
487
|
+
const uploadBtn = document.createElement('button')
|
|
488
|
+
uploadBtn.type = 'button'
|
|
489
|
+
uploadBtn.className = 'dsh-tw-note-upload'
|
|
490
|
+
uploadBtn.title = '上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)'
|
|
491
|
+
uploadBtn.textContent = '📎 上传'
|
|
492
|
+
const fileInput = document.createElement('input')
|
|
493
|
+
fileInput.type = 'file'
|
|
494
|
+
fileInput.multiple = true
|
|
495
|
+
fileInput.hidden = true
|
|
496
|
+
uploadBtn.addEventListener('click', () => { fileInput.click() })
|
|
497
|
+
fileInput.addEventListener('change', () => {
|
|
498
|
+
for (const file of Array.from(fileInput.files ?? [])) void uploadInto(file, editor)
|
|
499
|
+
fileInput.value = ''
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
let dragDepth = 0
|
|
503
|
+
editor.el.addEventListener('dragenter', (event) => {
|
|
504
|
+
event.preventDefault()
|
|
505
|
+
dragDepth++
|
|
506
|
+
editor.el.classList.add('dsh-tw-note-drop')
|
|
507
|
+
})
|
|
508
|
+
editor.el.addEventListener('dragover', (event) => { event.preventDefault() })
|
|
509
|
+
editor.el.addEventListener('dragleave', (event) => {
|
|
510
|
+
event.preventDefault()
|
|
511
|
+
dragDepth = Math.max(0, dragDepth - 1)
|
|
512
|
+
if (dragDepth === 0) editor.el.classList.remove('dsh-tw-note-drop')
|
|
513
|
+
})
|
|
514
|
+
editor.el.addEventListener('drop', (event) => {
|
|
515
|
+
event.preventDefault()
|
|
516
|
+
dragDepth = 0
|
|
517
|
+
editor.el.classList.remove('dsh-tw-note-drop')
|
|
518
|
+
const files = event.dataTransfer?.files
|
|
519
|
+
if (files === undefined || files.length === 0) return
|
|
520
|
+
for (const file of Array.from(files)) void uploadInto(file, editor)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
const foot = document.createElement('div')
|
|
524
|
+
foot.className = 'dsh-tw-note-foot'
|
|
525
|
+
const footLeft = document.createElement('div')
|
|
526
|
+
footLeft.className = 'dsh-tw-note-foot-left'
|
|
527
|
+
const hint = document.createElement('span')
|
|
528
|
+
hint.className = 'dsh-tw-note-hint'
|
|
529
|
+
hint.textContent = 'Ctrl+Enter'
|
|
530
|
+
const recentBtn = document.createElement('button')
|
|
531
|
+
recentBtn.type = 'button'
|
|
532
|
+
recentBtn.className = 'dsh-tw-note-recent-btn'
|
|
533
|
+
recentBtn.title = '最近修改的笔记,点击载入继续编辑'
|
|
534
|
+
recentBtn.textContent = '🕘 最近'
|
|
535
|
+
recentBtn.addEventListener('click', toggleRecent)
|
|
536
|
+
footLeft.append(uploadBtn, recentBtn, hint)
|
|
537
|
+
const footRight = document.createElement('div')
|
|
538
|
+
footRight.className = 'dsh-tw-note-foot-right'
|
|
539
|
+
const editBtn = document.createElement('button')
|
|
540
|
+
editBtn.type = 'button'
|
|
541
|
+
editBtn.className = 'dsh-tw-note-edit'
|
|
542
|
+
editBtn.title = '保存并在 TiddlyWiki 原生编辑器中打开'
|
|
543
|
+
editBtn.textContent = '✏️ 在 TW 中编辑'
|
|
544
|
+
const saveBtn = document.createElement('button')
|
|
545
|
+
saveBtn.type = 'button'
|
|
546
|
+
saveBtn.className = 'dsh-tw-note-save'
|
|
547
|
+
saveBtn.textContent = '保存'
|
|
548
|
+
footRight.append(editBtn, saveBtn)
|
|
549
|
+
foot.append(footLeft, footRight)
|
|
550
|
+
|
|
551
|
+
// Recent dropdown — absolute, pops above the card.
|
|
552
|
+
const recentWrap = document.createElement('div')
|
|
553
|
+
recentWrap.className = 'dsh-tw-note-recent'
|
|
554
|
+
recentWrap.hidden = true
|
|
555
|
+
|
|
556
|
+
card.append(head, draftBanner, fields, editor.el, foot)
|
|
557
|
+
root.append(card, recentWrap)
|
|
558
|
+
document.body.append(root)
|
|
559
|
+
|
|
560
|
+
const handle: BuiltUi = { root, card, editor, titleInput, tagEditor, saveBtn, editBtn, draftBanner, recentBtn, recentWrap }
|
|
561
|
+
ui = handle
|
|
562
|
+
|
|
563
|
+
const close = (): void => {
|
|
564
|
+
opened = false
|
|
565
|
+
root.hidden = true
|
|
566
|
+
closeRecent()
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const saveDone = (): void => {
|
|
570
|
+
clearDraft()
|
|
571
|
+
hideDraftBanner()
|
|
572
|
+
editor.setValue('')
|
|
573
|
+
titleInput.value = timestampTitle()
|
|
574
|
+
tagEditor.setTags([])
|
|
575
|
+
close()
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
doSave = async (): Promise<void> => {
|
|
579
|
+
const text = editor.getValue().trim()
|
|
580
|
+
if (text.length === 0) {
|
|
581
|
+
toast('内容为空,未保存')
|
|
582
|
+
return
|
|
583
|
+
}
|
|
584
|
+
saveBtn.disabled = true
|
|
585
|
+
saveBtn.textContent = '保存中…'
|
|
586
|
+
try {
|
|
587
|
+
const res = await fetch(NOTE_ENDPOINT, {
|
|
588
|
+
method: 'POST',
|
|
589
|
+
headers: { 'content-type': 'application/json' },
|
|
590
|
+
body: JSON.stringify({ title: titleInput.value.trim(), tags: tagEditor.getTags(), text }),
|
|
591
|
+
signal: AbortSignal.timeout(10_000),
|
|
592
|
+
})
|
|
593
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
|
|
594
|
+
if (!res.ok || payload?.ok !== true) {
|
|
595
|
+
toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
saveDone()
|
|
599
|
+
toast(`已保存「${payload.title ?? titleInput.value}」`)
|
|
600
|
+
} catch (err) {
|
|
601
|
+
toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
|
|
602
|
+
} finally {
|
|
603
|
+
saveBtn.disabled = false
|
|
604
|
+
saveBtn.textContent = '保存'
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
saveBtn.addEventListener('click', () => { void doSave?.() })
|
|
609
|
+
|
|
610
|
+
/** Save (if non-empty) and open the tiddler in TW's native editor. */
|
|
611
|
+
const doEdit = async (): Promise<void> => {
|
|
612
|
+
const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
|
|
613
|
+
const text = editor.getValue()
|
|
614
|
+
const tags = tagEditor.getTags()
|
|
615
|
+
editBtn.disabled = true
|
|
616
|
+
editBtn.textContent = '打开中…'
|
|
617
|
+
try {
|
|
618
|
+
const res = await fetch(EDIT_ENDPOINT, {
|
|
619
|
+
method: 'POST',
|
|
620
|
+
headers: { 'content-type': 'application/json' },
|
|
621
|
+
body: JSON.stringify({ title, tags, text }),
|
|
622
|
+
signal: AbortSignal.timeout(10_000),
|
|
623
|
+
})
|
|
624
|
+
const payload = (await res.json().catch(() => null)) as
|
|
625
|
+
| { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
|
|
626
|
+
| null
|
|
627
|
+
if (!res.ok || payload?.ok !== true) {
|
|
628
|
+
toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
|
|
629
|
+
return
|
|
630
|
+
}
|
|
631
|
+
if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
|
|
632
|
+
toast('打开失败:服务未返回编辑器地址')
|
|
633
|
+
return
|
|
634
|
+
}
|
|
635
|
+
clearDraft()
|
|
636
|
+
hideDraftBanner()
|
|
637
|
+
// twUrl is the same-origin proxy path (e.g. /dsh-tiddlywiki/tw/);
|
|
638
|
+
// resolve it against this page's origin so the popup works from any
|
|
639
|
+
// host/domain DSH is reached on (loopback, LAN, Tailscale, HTTPS).
|
|
640
|
+
const popupUrl = `${new URL(payload.twUrl, location.origin).href}#${encodeURIComponent(payload.draftTitle)}`
|
|
641
|
+
openEditorPopup(popupUrl, payload.title ?? title)
|
|
642
|
+
toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
|
|
643
|
+
} catch (err) {
|
|
644
|
+
toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
|
|
645
|
+
} finally {
|
|
646
|
+
editBtn.disabled = false
|
|
647
|
+
editBtn.textContent = '✏️ 在 TW 中编辑'
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
editBtn.addEventListener('click', () => { void doEdit() })
|
|
652
|
+
closeBtn.addEventListener('click', close)
|
|
653
|
+
|
|
654
|
+
// Clicking outside the card collapses it (but keeps the draft).
|
|
655
|
+
document.addEventListener('click', (event) => {
|
|
656
|
+
if (!opened) return
|
|
657
|
+
const target = event.target as Node
|
|
658
|
+
if (root.contains(target)) return
|
|
659
|
+
if (recentWrap.contains(target)) return
|
|
660
|
+
close()
|
|
661
|
+
}, true)
|
|
662
|
+
|
|
663
|
+
// Discard draft (banner button).
|
|
664
|
+
discardBtn.addEventListener('click', () => {
|
|
665
|
+
clearDraft()
|
|
666
|
+
hideDraftBanner()
|
|
667
|
+
editor.setValue('')
|
|
668
|
+
titleInput.value = timestampTitle()
|
|
669
|
+
tagEditor.setTags([])
|
|
670
|
+
toast('已丢弃草稿')
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ── public handle ────────────────────────────────────────────────────────
|
|
675
|
+
return {
|
|
676
|
+
async open() {
|
|
677
|
+
if (disposed) return
|
|
678
|
+
build()
|
|
679
|
+
if (ui === undefined || opened) return
|
|
680
|
+
opened = true
|
|
681
|
+
ui.root.hidden = false
|
|
682
|
+
const draft = loadDraft()
|
|
683
|
+
if (draft !== null && (draft.text.trim().length > 0 || draft.title.trim().length > 0)) {
|
|
684
|
+
// Restore the autosaved draft (survives reload / accidental close).
|
|
685
|
+
ui.editor.setValue(draft.text)
|
|
686
|
+
ui.titleInput.value = draft.title
|
|
687
|
+
ui.tagEditor.setTags(draft.tags)
|
|
688
|
+
ui.draftBanner.hidden = false
|
|
689
|
+
} else {
|
|
690
|
+
resetTitle()
|
|
691
|
+
defaultTag = (await fetchUiOptions()).defaultTag
|
|
692
|
+
ui.tagEditor.setDefault(defaultTag)
|
|
693
|
+
}
|
|
694
|
+
ui.editor.focus()
|
|
695
|
+
},
|
|
696
|
+
close() {
|
|
697
|
+
if (ui === undefined) return
|
|
698
|
+
opened = false
|
|
699
|
+
ui.root.hidden = true
|
|
700
|
+
closeRecent()
|
|
701
|
+
},
|
|
702
|
+
async toggle() {
|
|
703
|
+
if (opened) this.close()
|
|
704
|
+
else await this.open()
|
|
705
|
+
},
|
|
706
|
+
isOpen() {
|
|
707
|
+
return opened
|
|
708
|
+
},
|
|
709
|
+
dispose() {
|
|
710
|
+
disposed = true
|
|
711
|
+
if (draftTimer !== undefined) { clearTimeout(draftTimer); draftTimer = undefined }
|
|
712
|
+
ui?.editor.view.destroy()
|
|
713
|
+
ui?.root.remove()
|
|
714
|
+
const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
|
|
715
|
+
toastEl?.remove()
|
|
716
|
+
ui = undefined
|
|
717
|
+
},
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** Legacy compatibility export: the FAB owns the trigger in v0.5. */
|
|
722
|
+
export function mountNoteWidget(): () => void {
|
|
723
|
+
const handle = createNoteWidget()
|
|
724
|
+
return () => handle.dispose()
|
|
725
|
+
}
|