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.
@@ -1,475 +1,725 @@
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 CodeMirror 6 editor (see markdown-editor.ts) with real
18
- * Lezer-based syntax highlighting (GFM: headings, lists, code, links, tables,
19
- * task lists, strikethrough…) plus undo/redo, wired into the same
20
- * `.dsh-tw-note-editor` surface the widget reads/writes.
21
- *
22
- * File upload: the body accepts files via the 📎 button or drag-and-drop;
23
- * each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
24
- * `files/` folder, git-tracked and served at `/files/<name>`) and a Markdown
25
- * image/link line is inserted at the caret.
26
- *
27
- * @module dsh-tiddlywiki/client/note-widget
28
- */
29
- import { toast } from './toast.ts'
30
- import { openEditorPopup } from './editor-popup.ts'
31
- import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
32
- import type { EditorView } from '@codemirror/view'
33
-
34
- const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
35
- const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
36
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
37
- const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
38
- const UPLOAD_ENDPOINT = '/dsh-tiddlywiki/upload'
39
- const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
40
-
41
- function pad(n: number): string {
42
- return n < 10 ? `0${n}` : String(n)
43
- }
44
-
45
- /** Default note title: `YYYY-MM-DD HH:mm`. */
46
- function timestampTitle(date = new Date()): string {
47
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
48
- }
49
-
50
- interface UiOptions {
51
- showQuickNote: boolean
52
- defaultTag: string
53
- }
54
-
55
- async function fetchUiOptions(): Promise<UiOptions> {
56
- const fallback: UiOptions = { showQuickNote: true, defaultTag: 'inbox' }
57
- try {
58
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
59
- if (!res.ok) return fallback
60
- const payload = (await res.json()) as { ui?: { showQuickNote?: boolean }; note?: { tag?: string } }
61
- return {
62
- showQuickNote: payload.ui?.showQuickNote !== false,
63
- defaultTag: payload.note?.tag ?? 'inbox',
64
- }
65
- } catch {
66
- return fallback
67
- }
68
- }
69
-
70
- /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
71
- function buildTagEditor(): {
72
- el: HTMLDivElement
73
- getTags: () => string[]
74
- setDefault: (tag: string) => void
75
- } {
76
- const wrap = document.createElement('div')
77
- wrap.className = 'dsh-tw-note-tags'
78
-
79
- const chipWrap = document.createElement('div')
80
- chipWrap.className = 'dsh-tw-note-chips'
81
- const input = document.createElement('input')
82
- input.className = 'dsh-tw-note-taginput'
83
- input.placeholder = 'tag(可多选,自动补全)'
84
- const suggest = document.createElement('div')
85
- suggest.className = 'dsh-tw-note-tagsuggest'
86
- suggest.hidden = true
87
- wrap.append(chipWrap, input, suggest)
88
-
89
- const chips: string[] = []
90
- const hideSuggest = (): void => { suggest.hidden = true }
91
-
92
- const renderChips = (): void => {
93
- chipWrap.replaceChildren()
94
- for (const tag of chips) {
95
- const chip = document.createElement('span')
96
- chip.className = 'dsh-tw-note-tagchip'
97
- chip.textContent = tag
98
- const x = document.createElement('span')
99
- x.className = 'dsh-tw-note-tagchip-x'
100
- x.textContent = '×'
101
- x.title = `移除 tag「${tag}」`
102
- x.addEventListener('click', (event) => {
103
- event.stopPropagation()
104
- const i = chips.indexOf(tag)
105
- if (i >= 0) {
106
- chips.splice(i, 1)
107
- renderChips()
108
- }
109
- })
110
- chip.append(x)
111
- chipWrap.append(chip)
112
- }
113
- }
114
-
115
- const addTag = (tag: string): void => {
116
- const t = tag.trim()
117
- if (t.length === 0 || chips.includes(t)) return
118
- chips.push(t)
119
- input.value = ''
120
- renderChips()
121
- hideSuggest()
122
- input.focus()
123
- }
124
-
125
- const commitInput = (): void => {
126
- for (const raw of input.value.split(/\s+/)) addTag(raw)
127
- }
128
-
129
- // Existing tags, fetched lazily once per widget lifetime.
130
- let knownTags: string[] = []
131
- let tagsPromise: Promise<string[]> | undefined
132
- const ensureTags = (): Promise<string[]> => {
133
- tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
134
- .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
135
- .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
136
- .catch(() => [])
137
- void tagsPromise.then((list) => { knownTags = list })
138
- return tagsPromise
139
- }
140
-
141
- const showSuggest = (): void => {
142
- const q = input.value.trim().toLowerCase()
143
- const matches = knownTags
144
- .filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
145
- .slice(0, 8)
146
- suggest.replaceChildren()
147
- for (const tag of matches) {
148
- const item = document.createElement('div')
149
- item.className = 'dsh-tw-note-tagsuggest-item'
150
- item.textContent = tag
151
- item.addEventListener('mousedown', (event) => {
152
- event.preventDefault()
153
- addTag(tag)
154
- })
155
- suggest.append(item)
156
- }
157
- suggest.hidden = matches.length === 0
158
- }
159
-
160
- input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
161
- input.addEventListener('input', () => {
162
- if (knownTags.length === 0) void ensureTags().then(showSuggest)
163
- else showSuggest()
164
- })
165
- input.addEventListener('keydown', (event) => {
166
- if (event.key === 'Enter' || event.key === ',') {
167
- event.preventDefault()
168
- commitInput()
169
- } else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
170
- chips.pop()
171
- renderChips()
172
- } else if (event.key === 'Escape') {
173
- hideSuggest()
174
- }
175
- })
176
- document.addEventListener('click', (event) => {
177
- if (!wrap.contains(event.target as Node)) hideSuggest()
178
- }, true)
179
-
180
- return {
181
- el: wrap,
182
- getTags: () => {
183
- commitInput()
184
- return [...chips]
185
- },
186
- setDefault: (tag: string) => {
187
- // Only pre-fill when nothing is chosen yet; never steal focus.
188
- if (chips.length === 0) {
189
- const t = tag.trim()
190
- if (t.length > 0) {
191
- chips.push(t)
192
- renderChips()
193
- }
194
- }
195
- },
196
- }
197
- }
198
-
199
- /**
200
- * Build the CodeMirror 6 Markdown editor for the quick-note widget. The editor
201
- * lives inside `.dsh-tw-note-editor`; the widget reads/writes through
202
- * getValue/setValue and inserts uploaded files at the caret via insertAtCaret.
203
- * Mod-Enter (save) is routed through `onSave`.
204
- */
205
- function buildEditor(onSave: () => void): MarkdownEditor {
206
- return buildMarkdownEditor({
207
- placeholder: '写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存',
208
- onSave,
209
- })
210
- }
211
-
212
- /**
213
- * Upload one file to the wiki (raw body, name in ?name=), then insert a
214
- * Markdown image/link line at the caret of the given editor.
215
- */
216
- async function uploadInto(file: File, editor: MarkdownEditor): Promise<void> {
217
- if (file.size > MAX_UPLOAD_BYTES) {
218
- toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
219
- return
220
- }
221
- try {
222
- const res = await fetch(`${UPLOAD_ENDPOINT}?name=${encodeURIComponent(file.name)}`, {
223
- method: 'POST',
224
- headers: { 'content-type': file.type || 'application/octet-stream' },
225
- body: file,
226
- signal: AbortSignal.timeout(120_000),
227
- })
228
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; name?: string; url?: string; error?: string } | null
229
- if (!res.ok || payload?.ok !== true) {
230
- toast(`上传失败:${payload?.error ?? `HTTP ${res.status}`}`)
231
- return
232
- }
233
- const name = payload.name ?? file.name
234
- const markdown = file.type.startsWith('image/')
235
- ? `![${name}](${payload.url})`
236
- : `[${name}](${payload.url})`
237
- editor.insertAtCaret(markdown)
238
- toast(`已上传「${name}」并插入链接`)
239
- } catch (err) {
240
- toast(`上传失败:${err instanceof Error ? err.message : String(err)}`)
241
- }
242
- }
243
-
244
- /** Build the whole widget DOM and wire it up (returns root + editor view). */
245
- function buildWidget(): { root: HTMLDivElement; editorView: EditorView } {
246
- const root = document.createElement('div')
247
- root.className = 'dsh-tw-note'
248
-
249
- const card = document.createElement('div')
250
- card.className = 'dsh-tw-note-card'
251
- card.hidden = true
252
-
253
- const head = document.createElement('div')
254
- head.className = 'dsh-tw-note-head'
255
- const label = document.createElement('span')
256
- label.className = 'dsh-tw-note-label'
257
- label.textContent = '📝 快速笔记'
258
- const closeBtn = document.createElement('button')
259
- closeBtn.type = 'button'
260
- closeBtn.className = 'dsh-tw-note-close'
261
- closeBtn.title = '收起'
262
- closeBtn.textContent = '✕'
263
- head.append(label, closeBtn)
264
-
265
- const fields = document.createElement('div')
266
- fields.className = 'dsh-tw-note-fields'
267
- const titleInput = document.createElement('input')
268
- titleInput.className = 'dsh-tw-note-title'
269
- titleInput.placeholder = '标题(默认时间戳)'
270
- const tagEditor = buildTagEditor()
271
- fields.append(titleInput, tagEditor.el)
272
-
273
- // Mod-Enter save routes through doSave, which is assigned below (the editor
274
- // keymap only runs after the widget is fully wired, so this is safe).
275
- let doSave: () => Promise<void> | undefined
276
- const editor = buildEditor(() => { void doSave?.() })
277
-
278
- // ── file upload (button + drag & drop onto the editor) ────────────────
279
- const uploadBtn = document.createElement('button')
280
- uploadBtn.type = 'button'
281
- uploadBtn.className = 'dsh-tw-note-upload'
282
- uploadBtn.title = '上传文件到 wiki 并插入 Markdown 链接(也可直接拖入编辑器)'
283
- uploadBtn.textContent = '📎 上传'
284
- const fileInput = document.createElement('input')
285
- fileInput.type = 'file'
286
- fileInput.multiple = true
287
- fileInput.hidden = true
288
- uploadBtn.addEventListener('click', () => { fileInput.click() })
289
- fileInput.addEventListener('change', () => {
290
- for (const file of Array.from(fileInput.files ?? [])) void uploadInto(file, editor)
291
- fileInput.value = ''
292
- })
293
-
294
- let dragDepth = 0
295
- editor.el.addEventListener('dragenter', (event) => {
296
- event.preventDefault()
297
- dragDepth++
298
- editor.el.classList.add('dsh-tw-note-drop')
299
- })
300
- editor.el.addEventListener('dragover', (event) => { event.preventDefault() })
301
- editor.el.addEventListener('dragleave', (event) => {
302
- event.preventDefault()
303
- dragDepth = Math.max(0, dragDepth - 1)
304
- if (dragDepth === 0) editor.el.classList.remove('dsh-tw-note-drop')
305
- })
306
- editor.el.addEventListener('drop', (event) => {
307
- event.preventDefault()
308
- dragDepth = 0
309
- editor.el.classList.remove('dsh-tw-note-drop')
310
- const files = event.dataTransfer?.files
311
- if (files === undefined || files.length === 0) return
312
- for (const file of Array.from(files)) void uploadInto(file, editor)
313
- })
314
-
315
- const foot = document.createElement('div')
316
- foot.className = 'dsh-tw-note-foot'
317
- const footLeft = document.createElement('div')
318
- footLeft.className = 'dsh-tw-note-foot-left'
319
- const hint = document.createElement('span')
320
- hint.className = 'dsh-tw-note-hint'
321
- hint.textContent = 'Ctrl+Enter'
322
- footLeft.append(uploadBtn, hint)
323
- const footRight = document.createElement('div')
324
- footRight.className = 'dsh-tw-note-foot-right'
325
- const edit = document.createElement('button')
326
- edit.type = 'button'
327
- edit.className = 'dsh-tw-note-edit'
328
- edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
329
- edit.textContent = '✏️ 在 TW 中编辑'
330
- const save = document.createElement('button')
331
- save.type = 'button'
332
- save.className = 'dsh-tw-note-save'
333
- save.textContent = '保存'
334
- footRight.append(edit, save)
335
- foot.append(footLeft, footRight)
336
-
337
- card.append(head, fields, editor.el, foot)
338
-
339
- const toggle = document.createElement('button')
340
- toggle.type = 'button'
341
- toggle.className = 'dsh-tw-note-toggle'
342
- toggle.textContent = '📝 快速笔记'
343
-
344
- root.append(card, toggle)
345
- document.body.append(root)
346
-
347
- let defaultTag = 'inbox'
348
- let opened = false
349
-
350
- const resetTitle = (): void => {
351
- titleInput.value = timestampTitle()
352
- }
353
-
354
- const open = async (): Promise<void> => {
355
- card.hidden = false
356
- opened = true
357
- resetTitle()
358
- editor.focus()
359
- // Fetch the effective default tag once, then pre-fill if nothing is chosen.
360
- defaultTag = (await fetchUiOptions()).defaultTag
361
- tagEditor.setDefault(defaultTag)
362
- }
363
-
364
- const close = (): void => {
365
- card.hidden = true
366
- opened = false
367
- }
368
-
369
- toggle.addEventListener('click', () => { void (opened ? close() : open()) })
370
- closeBtn.addEventListener('click', close)
371
-
372
- doSave = async (): Promise<void> => {
373
- const text = editor.getValue().trim()
374
- if (text.length === 0) {
375
- toast('内容为空,未保存')
376
- return
377
- }
378
- save.disabled = true
379
- save.textContent = '保存中…'
380
- try {
381
- const res = await fetch(NOTE_ENDPOINT, {
382
- method: 'POST',
383
- headers: { 'content-type': 'application/json' },
384
- body: JSON.stringify({
385
- title: titleInput.value.trim(),
386
- tags: tagEditor.getTags(),
387
- text,
388
- }),
389
- signal: AbortSignal.timeout(10_000),
390
- })
391
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
392
- if (!res.ok || payload?.ok !== true) {
393
- toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
394
- return
395
- }
396
- editor.setValue('')
397
- resetTitle()
398
- toast(`已保存「${payload.title ?? titleInput.value}」`)
399
- close()
400
- } catch (err) {
401
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
402
- } finally {
403
- save.disabled = false
404
- save.textContent = '保存'
405
- }
406
- }
407
-
408
- save.addEventListener('click', () => { void doSave?.() })
409
-
410
- /** Save (if non-empty) and open the tiddler in TW's native editor. */
411
- const doEdit = async (): Promise<void> => {
412
- const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
413
- const text = editor.getValue()
414
- const tags = tagEditor.getTags()
415
- edit.disabled = true
416
- edit.textContent = '打开中…'
417
- try {
418
- const res = await fetch(EDIT_ENDPOINT, {
419
- method: 'POST',
420
- headers: { 'content-type': 'application/json' },
421
- body: JSON.stringify({ title, tags, text }),
422
- signal: AbortSignal.timeout(10_000),
423
- })
424
- const payload = (await res.json().catch(() => null)) as
425
- | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
426
- | null
427
- if (!res.ok || payload?.ok !== true) {
428
- toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
429
- return
430
- }
431
- if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
432
- toast('打开失败:服务未返回编辑器地址')
433
- return
434
- }
435
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
436
- toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
437
- } catch (err) {
438
- toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
439
- } finally {
440
- edit.disabled = false
441
- edit.textContent = '✏️ 在 TW 中编辑'
442
- }
443
- }
444
-
445
- edit.addEventListener('click', () => { void doEdit() })
446
-
447
- return { root, editorView: editor.view }
448
- }
449
-
450
- /**
451
- * Mount the floating quick-note widget. Fetches /status first: when
452
- * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
453
- * Returns a disposer that removes it (and destroys the CodeMirror view).
454
- */
455
- export function mountNoteWidget(): () => void {
456
- let disposed = false
457
- let root: HTMLDivElement | undefined
458
- let editorView: EditorView | undefined
459
- const disposer = (): void => {
460
- disposed = true
461
- // CodeMirror holds a ResizeObserver + timers; destroy it, not just the DOM.
462
- editorView?.destroy()
463
- root?.remove()
464
- const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
465
- toastEl?.remove()
466
- }
467
- void (async () => {
468
- const ui = await fetchUiOptions()
469
- if (disposed || !ui.showQuickNote) return
470
- const widget = buildWidget()
471
- root = widget.root
472
- editorView = widget.editorView
473
- })()
474
- return disposer
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
+ ? `![${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
+ // 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
+ }