dsh-tiddlywiki 0.3.1 → 0.5.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.
@@ -1,515 +1,721 @@
1
- /**
2
- * Floating quick-note widget (design doc §12, D6/D7) — bottom-right, fixed,
3
- * collapsible, independent of any shell DOM. Lets the human jot drafts /
4
- * scratch notes while waiting for the AI or drafting the next prompt.
5
- *
6
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
7
- * editable; defaults: timestamp title + config tag, usually "inbox").
8
- *
9
- * ui.showQuickNote config: the widget is mounted async and stays hidden (and
10
- * never appended) when the option is off, so the toggle button can be disabled
11
- * from the settings page without touching the shell.
12
- *
13
- * Tag editor: multi-select chips + autocomplete from the wiki's existing tags
14
- * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
15
- * an empty field removes the last chip; the dropdown filters on typing.
16
- *
17
- * Markdown editing: a "highlighted textarea" overlay (a `<pre>` rendered
18
- * behind a transparent-text `<textarea>`, see markdown.ts) so Markdown is
19
- * syntax-highlighted as you type — no dependencies, perfect alignment.
20
- *
21
- * File upload: the body accepts files via the 📎 button or drag-and-drop;
22
- * each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
23
- * `files/` folder, git-tracked and served at `/files/<name>`) and a Markdown
24
- * image/link line is inserted at the caret.
25
- *
26
- * @module dsh-tiddlywiki/client/note-widget
27
- */
28
- import { toast } from './toast.ts'
29
- import { openEditorPopup } from './editor-popup.ts'
30
- import { highlightMarkdown } from './markdown.ts'
31
-
32
- const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
33
- const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
34
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
35
- const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
36
- const UPLOAD_ENDPOINT = '/dsh-tiddlywiki/upload'
37
- const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
38
-
39
- function pad(n: number): string {
40
- return n < 10 ? `0${n}` : String(n)
41
- }
42
-
43
- /** Default note title: `YYYY-MM-DD HH:mm`. */
44
- function timestampTitle(date = new Date()): string {
45
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
46
- }
47
-
48
- interface UiOptions {
49
- showQuickNote: boolean
50
- defaultTag: string
51
- }
52
-
53
- async function fetchUiOptions(): Promise<UiOptions> {
54
- const fallback: UiOptions = { showQuickNote: true, defaultTag: 'inbox' }
55
- try {
56
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
57
- if (!res.ok) return fallback
58
- const payload = (await res.json()) as { ui?: { showQuickNote?: boolean }; note?: { tag?: string } }
59
- return {
60
- showQuickNote: payload.ui?.showQuickNote !== false,
61
- defaultTag: payload.note?.tag ?? 'inbox',
62
- }
63
- } catch {
64
- return fallback
65
- }
66
- }
67
-
68
- /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
69
- function buildTagEditor(): {
70
- el: HTMLDivElement
71
- getTags: () => string[]
72
- setDefault: (tag: string) => void
73
- } {
74
- const wrap = document.createElement('div')
75
- wrap.className = 'dsh-tw-note-tags'
76
-
77
- const chipWrap = document.createElement('div')
78
- chipWrap.className = 'dsh-tw-note-chips'
79
- const input = document.createElement('input')
80
- input.className = 'dsh-tw-note-taginput'
81
- input.placeholder = 'tag(可多选,自动补全)'
82
- const suggest = document.createElement('div')
83
- suggest.className = 'dsh-tw-note-tagsuggest'
84
- suggest.hidden = true
85
- wrap.append(chipWrap, input, suggest)
86
-
87
- const chips: string[] = []
88
- const hideSuggest = (): void => { suggest.hidden = true }
89
-
90
- const renderChips = (): void => {
91
- chipWrap.replaceChildren()
92
- for (const tag of chips) {
93
- const chip = document.createElement('span')
94
- chip.className = 'dsh-tw-note-tagchip'
95
- chip.textContent = tag
96
- const x = document.createElement('span')
97
- x.className = 'dsh-tw-note-tagchip-x'
98
- x.textContent = '×'
99
- x.title = `移除 tag「${tag}」`
100
- x.addEventListener('click', (event) => {
101
- event.stopPropagation()
102
- const i = chips.indexOf(tag)
103
- if (i >= 0) {
104
- chips.splice(i, 1)
105
- renderChips()
106
- }
107
- })
108
- chip.append(x)
109
- chipWrap.append(chip)
110
- }
111
- }
112
-
113
- const addTag = (tag: string): void => {
114
- const t = tag.trim()
115
- if (t.length === 0 || chips.includes(t)) return
116
- chips.push(t)
117
- input.value = ''
118
- renderChips()
119
- hideSuggest()
120
- input.focus()
121
- }
122
-
123
- const commitInput = (): void => {
124
- for (const raw of input.value.split(/\s+/)) addTag(raw)
125
- }
126
-
127
- // Existing tags, fetched lazily once per widget lifetime.
128
- let knownTags: string[] = []
129
- let tagsPromise: Promise<string[]> | undefined
130
- const ensureTags = (): Promise<string[]> => {
131
- tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
132
- .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
133
- .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
134
- .catch(() => [])
135
- void tagsPromise.then((list) => { knownTags = list })
136
- return tagsPromise
137
- }
138
-
139
- const showSuggest = (): void => {
140
- const q = input.value.trim().toLowerCase()
141
- const matches = knownTags
142
- .filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
143
- .slice(0, 8)
144
- suggest.replaceChildren()
145
- for (const tag of matches) {
146
- const item = document.createElement('div')
147
- item.className = 'dsh-tw-note-tagsuggest-item'
148
- item.textContent = tag
149
- item.addEventListener('mousedown', (event) => {
150
- event.preventDefault()
151
- addTag(tag)
152
- })
153
- suggest.append(item)
154
- }
155
- suggest.hidden = matches.length === 0
156
- }
157
-
158
- input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
159
- input.addEventListener('input', () => {
160
- if (knownTags.length === 0) void ensureTags().then(showSuggest)
161
- else showSuggest()
162
- })
163
- input.addEventListener('keydown', (event) => {
164
- if (event.key === 'Enter' || event.key === ',') {
165
- event.preventDefault()
166
- commitInput()
167
- } else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
168
- chips.pop()
169
- renderChips()
170
- } else if (event.key === 'Escape') {
171
- hideSuggest()
172
- }
173
- })
174
- document.addEventListener('click', (event) => {
175
- if (!wrap.contains(event.target as Node)) hideSuggest()
176
- }, true)
177
-
178
- return {
179
- el: wrap,
180
- getTags: () => {
181
- commitInput()
182
- return [...chips]
183
- },
184
- setDefault: (tag: string) => {
185
- // Only pre-fill when nothing is chosen yet; never steal focus.
186
- if (chips.length === 0) {
187
- const t = tag.trim()
188
- if (t.length > 0) {
189
- chips.push(t)
190
- renderChips()
191
- }
192
- }
193
- },
194
- }
195
- }
196
-
197
- /**
198
- * Markdown editor: a `<pre>` overlay (highlighted by markdown.ts) rendered
199
- * behind a transparent-text `<textarea>` with identical metrics. Scroll is
200
- * mirrored; on every input the overlay is re-rendered. The text layer stays
201
- * exactly aligned with the highlight layer because the highlighter never
202
- * changes the user's characters.
203
- */
204
- function buildEditor(): {
205
- el: HTMLDivElement
206
- textarea: HTMLTextAreaElement
207
- render: () => void
208
- focus: () => void
209
- } {
210
- const wrap = document.createElement('div')
211
- wrap.className = 'dsh-tw-note-editor'
212
-
213
- const hl = document.createElement('pre')
214
- hl.className = 'dsh-tw-note-hl'
215
- hl.setAttribute('aria-hidden', 'true')
216
-
217
- const textarea = document.createElement('textarea')
218
- textarea.className = 'dsh-tw-note-text'
219
- textarea.placeholder = '写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存'
220
-
221
- wrap.append(hl, textarea)
222
-
223
- const render = (): void => {
224
- hl.innerHTML = `${highlightMarkdown(textarea.value)}\n`
225
- hl.scrollTop = textarea.scrollTop
226
- hl.scrollLeft = textarea.scrollLeft
227
- }
228
-
229
- const syncScroll = (): void => {
230
- hl.scrollTop = textarea.scrollTop
231
- hl.scrollLeft = textarea.scrollLeft
232
- }
233
-
234
- textarea.addEventListener('input', render)
235
- textarea.addEventListener('scroll', syncScroll)
236
- render()
237
-
238
- return { el: wrap, textarea, render, focus: () => textarea.focus() }
239
- }
240
-
241
- /**
242
- * Upload one file to the wiki (raw body, name in ?name=), then insert a
243
- * Markdown image/link line at the caret of the given editor.
244
- */
245
- async function uploadInto(file: File, editor: { textarea: HTMLTextAreaElement; render: () => void }): Promise<void> {
246
- if (file.size > MAX_UPLOAD_BYTES) {
247
- toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
248
- return
249
- }
250
- try {
251
- const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
252
- method: 'POST',
253
- headers: { 'content-type': file.type || 'application/octet-stream' },
254
- body: file,
255
- signal: AbortSignal.timeout(120_000),
256
- })
257
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; name?: string; url?: string; error?: string } | null
258
- if (!res.ok || payload?.ok !== true) {
259
- toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`)
260
- return
261
- }
262
- const name = payload.name ?? file.name
263
- const markdown = file.type.startsWith('image/')
264
- ? `![${name}](${payload.url})`
265
- : `[${name}](${payload.url})`
266
- const ta = editor.textarea
267
- const start = ta.selectionStart ?? ta.value.length
268
- const end = ta.selectionEnd ?? start
269
- const before = ta.value.slice(0, start)
270
- const after = ta.value.slice(end)
271
- const needsLead = start > 0 && !before.endsWith('\n')
272
- const insert = `${needsLead ? '\n' : ''}${markdown}\n`
273
- ta.value = `${before}${insert}${after}`
274
- const caret = (before + insert).length
275
- ta.setSelectionRange(caret, caret)
276
- ta.focus()
277
- editor.render()
278
- toast(`已上传「${name}」并插入链接`)
279
- } catch (err) {
280
- toast(`上传失败:${err instanceof Error ? err.message : String(err)}`)
281
- }
282
- }
283
-
284
- /** Build the whole widget DOM and wire it up (returns the appended root). */
285
- function buildWidget(): HTMLDivElement {
286
- const root = document.createElement('div')
287
- root.className = 'dsh-tw-note'
288
-
289
- const card = document.createElement('div')
290
- card.className = 'dsh-tw-note-card'
291
- card.hidden = true
292
-
293
- const head = document.createElement('div')
294
- head.className = 'dsh-tw-note-head'
295
- const label = document.createElement('span')
296
- label.className = 'dsh-tw-note-label'
297
- label.textContent = '📝 快速笔记'
298
- const closeBtn = document.createElement('button')
299
- closeBtn.type = 'button'
300
- closeBtn.className = 'dsh-tw-note-close'
301
- closeBtn.title = '收起'
302
- closeBtn.textContent = '✕'
303
- head.append(label, closeBtn)
304
-
305
- const fields = document.createElement('div')
306
- fields.className = 'dsh-tw-note-fields'
307
- const titleInput = document.createElement('input')
308
- titleInput.className = 'dsh-tw-note-title'
309
- titleInput.placeholder = '标题(默认时间戳)'
310
- const tagEditor = buildTagEditor()
311
- fields.append(titleInput, tagEditor.el)
312
-
313
- const editor = buildEditor()
314
- const textarea = editor.textarea
315
-
316
- // ── file upload (button + drag & drop onto the editor) ────────────────
317
- const uploadBtn = document.createElement('button')
318
- uploadBtn.type = 'button'
319
- uploadBtn.className = 'dsh-tw-note-upload'
320
- uploadBtn.title = '上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)'
321
- uploadBtn.textContent = '📎 上传'
322
- const fileInput = document.createElement('input')
323
- fileInput.type = 'file'
324
- fileInput.multiple = true
325
- fileInput.hidden = true
326
- uploadBtn.addEventListener('click', () => { fileInput.click() })
327
- fileInput.addEventListener('change', () => {
328
- for (const file of Array.from(fileInput.files ?? [])) void uploadInto(file, editor)
329
- fileInput.value = ''
330
- })
331
-
332
- let dragDepth = 0
333
- editor.el.addEventListener('dragenter', (event) => {
334
- event.preventDefault()
335
- dragDepth++
336
- editor.el.classList.add('dsh-tw-note-drop')
337
- })
338
- editor.el.addEventListener('dragover', (event) => { event.preventDefault() })
339
- editor.el.addEventListener('dragleave', (event) => {
340
- event.preventDefault()
341
- dragDepth = Math.max(0, dragDepth - 1)
342
- if (dragDepth === 0) editor.el.classList.remove('dsh-tw-note-drop')
343
- })
344
- editor.el.addEventListener('drop', (event) => {
345
- event.preventDefault()
346
- dragDepth = 0
347
- editor.el.classList.remove('dsh-tw-note-drop')
348
- const files = event.dataTransfer?.files
349
- if (files === undefined || files.length === 0) return
350
- for (const file of Array.from(files)) void uploadInto(file, editor)
351
- })
352
-
353
- const foot = document.createElement('div')
354
- foot.className = 'dsh-tw-note-foot'
355
- const footLeft = document.createElement('div')
356
- footLeft.className = 'dsh-tw-note-foot-left'
357
- const hint = document.createElement('span')
358
- hint.className = 'dsh-tw-note-hint'
359
- hint.textContent = 'Ctrl+Enter'
360
- footLeft.append(uploadBtn, hint)
361
- const footRight = document.createElement('div')
362
- footRight.className = 'dsh-tw-note-foot-right'
363
- const edit = document.createElement('button')
364
- edit.type = 'button'
365
- edit.className = 'dsh-tw-note-edit'
366
- edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
367
- edit.textContent = '✏️ 在 TW 中编辑'
368
- const save = document.createElement('button')
369
- save.type = 'button'
370
- save.className = 'dsh-tw-note-save'
371
- save.textContent = '保存'
372
- footRight.append(edit, save)
373
- foot.append(footLeft, footRight)
374
-
375
- card.append(head, fields, editor.el, foot)
376
-
377
- const toggle = document.createElement('button')
378
- toggle.type = 'button'
379
- toggle.className = 'dsh-tw-note-toggle'
380
- toggle.textContent = '📝 快速笔记'
381
-
382
- root.append(card, toggle)
383
- document.body.append(root)
384
-
385
- let defaultTag = 'inbox'
386
- let opened = false
387
-
388
- const resetTitle = (): void => {
389
- titleInput.value = timestampTitle()
390
- }
391
-
392
- const open = async (): Promise<void> => {
393
- card.hidden = false
394
- opened = true
395
- resetTitle()
396
- editor.focus()
397
- // Fetch the effective default tag once, then pre-fill if nothing is chosen.
398
- defaultTag = (await fetchUiOptions()).defaultTag
399
- tagEditor.setDefault(defaultTag)
400
- }
401
-
402
- const close = (): void => {
403
- card.hidden = true
404
- opened = false
405
- }
406
-
407
- toggle.addEventListener('click', () => { void (opened ? close() : open()) })
408
- closeBtn.addEventListener('click', close)
409
-
410
- const doSave = async (): Promise<void> => {
411
- const text = textarea.value.trim()
412
- if (text.length === 0) {
413
- toast('内容为空,未保存')
414
- return
415
- }
416
- save.disabled = true
417
- save.textContent = '保存中…'
418
- try {
419
- const res = await fetch(NOTE_ENDPOINT, {
420
- method: 'POST',
421
- headers: { 'content-type': 'application/json' },
422
- body: JSON.stringify({
423
- title: titleInput.value.trim(),
424
- tags: tagEditor.getTags(),
425
- text,
426
- }),
427
- signal: AbortSignal.timeout(10_000),
428
- })
429
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
430
- if (!res.ok || payload?.ok !== true) {
431
- toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
432
- return
433
- }
434
- textarea.value = ''
435
- editor.render()
436
- resetTitle()
437
- toast(`已保存「${payload.title ?? titleInput.value}」`)
438
- close()
439
- } catch (err) {
440
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
441
- } finally {
442
- save.disabled = false
443
- save.textContent = '保存'
444
- }
445
- }
446
-
447
- save.addEventListener('click', () => { void doSave() })
448
- textarea.addEventListener('keydown', (event) => {
449
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
450
- event.preventDefault()
451
- void doSave()
452
- }
453
- })
454
-
455
- /** Save (if non-empty) and open the tiddler in TW's native editor. */
456
- const doEdit = async (): Promise<void> => {
457
- const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
458
- const text = textarea.value
459
- const tags = tagEditor.getTags()
460
- edit.disabled = true
461
- edit.textContent = '打开中…'
462
- try {
463
- const res = await fetch(EDIT_ENDPOINT, {
464
- method: 'POST',
465
- headers: { 'content-type': 'application/json' },
466
- body: JSON.stringify({ title, tags, text }),
467
- signal: AbortSignal.timeout(10_000),
468
- })
469
- const payload = (await res.json().catch(() => null)) as
470
- | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
471
- | null
472
- if (!res.ok || payload?.ok !== true) {
473
- toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
474
- return
475
- }
476
- if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
477
- toast('打开失败:服务未返回编辑器地址')
478
- return
479
- }
480
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
481
- toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
482
- } catch (err) {
483
- toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
484
- } finally {
485
- edit.disabled = false
486
- edit.textContent = '✏️ 在 TW 中编辑'
487
- }
488
- }
489
-
490
- edit.addEventListener('click', () => { void doEdit() })
491
-
492
- return root
493
- }
494
-
495
- /**
496
- * Mount the floating quick-note widget. Fetches /status first: when
497
- * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
498
- * Returns a disposer that removes it.
499
- */
500
- export function mountNoteWidget(): () => void {
501
- let disposed = false
502
- let root: HTMLDivElement | undefined
503
- const disposer = (): void => {
504
- disposed = true
505
- root?.remove()
506
- const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
507
- toastEl?.remove()
508
- }
509
- void (async () => {
510
- const ui = await fetchUiOptions()
511
- if (disposed || !ui.showQuickNote) return
512
- root = buildWidget()
513
- })()
514
- return disposer
515
- }
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
+ ? `![${name}](${payload.url})`
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
+ openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
638
+ toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
639
+ } catch (err) {
640
+ toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
641
+ } finally {
642
+ editBtn.disabled = false
643
+ editBtn.textContent = '✏️ 在 TW 中编辑'
644
+ }
645
+ }
646
+
647
+ editBtn.addEventListener('click', () => { void doEdit() })
648
+ closeBtn.addEventListener('click', close)
649
+
650
+ // Clicking outside the card collapses it (but keeps the draft).
651
+ document.addEventListener('click', (event) => {
652
+ if (!opened) return
653
+ const target = event.target as Node
654
+ if (root.contains(target)) return
655
+ if (recentWrap.contains(target)) return
656
+ close()
657
+ }, true)
658
+
659
+ // Discard draft (banner button).
660
+ discardBtn.addEventListener('click', () => {
661
+ clearDraft()
662
+ hideDraftBanner()
663
+ editor.setValue('')
664
+ titleInput.value = timestampTitle()
665
+ tagEditor.setTags([])
666
+ toast('已丢弃草稿')
667
+ })
668
+ }
669
+
670
+ // ── public handle ────────────────────────────────────────────────────────
671
+ return {
672
+ async open() {
673
+ if (disposed) return
674
+ build()
675
+ if (ui === undefined || opened) return
676
+ opened = true
677
+ ui.root.hidden = false
678
+ const draft = loadDraft()
679
+ if (draft !== null && (draft.text.trim().length > 0 || draft.title.trim().length > 0)) {
680
+ // Restore the autosaved draft (survives reload / accidental close).
681
+ ui.editor.setValue(draft.text)
682
+ ui.titleInput.value = draft.title
683
+ ui.tagEditor.setTags(draft.tags)
684
+ ui.draftBanner.hidden = false
685
+ } else {
686
+ resetTitle()
687
+ defaultTag = (await fetchUiOptions()).defaultTag
688
+ ui.tagEditor.setDefault(defaultTag)
689
+ }
690
+ ui.editor.focus()
691
+ },
692
+ close() {
693
+ if (ui === undefined) return
694
+ opened = false
695
+ ui.root.hidden = true
696
+ closeRecent()
697
+ },
698
+ async toggle() {
699
+ if (opened) this.close()
700
+ else await this.open()
701
+ },
702
+ isOpen() {
703
+ return opened
704
+ },
705
+ dispose() {
706
+ disposed = true
707
+ if (draftTimer !== undefined) { clearTimeout(draftTimer); draftTimer = undefined }
708
+ ui?.editor.view.destroy()
709
+ ui?.root.remove()
710
+ const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
711
+ toastEl?.remove()
712
+ ui = undefined
713
+ },
714
+ }
715
+ }
716
+
717
+ /** Legacy compatibility export: the FAB owns the trigger in v0.5. */
718
+ export function mountNoteWidget(): () => void {
719
+ const handle = createNoteWidget()
720
+ return () => handle.dispose()
721
+ }