dsh-tiddlywiki 0.2.0 → 0.3.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,373 +1,515 @@
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
- * @module dsh-tiddlywiki/client/note-widget
18
- */
19
- import { toast } from './toast.ts'
20
- import { openEditorPopup } from './editor-popup.ts'
21
-
22
- const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
23
- const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
24
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
25
- const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
26
-
27
- function pad(n: number): string {
28
- return n < 10 ? `0${n}` : String(n)
29
- }
30
-
31
- /** Default note title: `YYYY-MM-DD HH:mm`. */
32
- function timestampTitle(date = new Date()): string {
33
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
34
- }
35
-
36
- interface UiOptions {
37
- showQuickNote: boolean
38
- defaultTag: string
39
- }
40
-
41
- async function fetchUiOptions(): Promise<UiOptions> {
42
- const fallback: UiOptions = { showQuickNote: true, defaultTag: 'inbox' }
43
- try {
44
- const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
45
- if (!res.ok) return fallback
46
- const payload = (await res.json()) as { ui?: { showQuickNote?: boolean }; note?: { tag?: string } }
47
- return {
48
- showQuickNote: payload.ui?.showQuickNote !== false,
49
- defaultTag: payload.note?.tag ?? 'inbox',
50
- }
51
- } catch {
52
- return fallback
53
- }
54
- }
55
-
56
- /** Multi-tag chip editor with autocomplete from the wiki's existing tags. */
57
- function buildTagEditor(): {
58
- el: HTMLDivElement
59
- getTags: () => string[]
60
- setDefault: (tag: string) => void
61
- } {
62
- const wrap = document.createElement('div')
63
- wrap.className = 'dsh-tw-note-tags'
64
-
65
- const chipWrap = document.createElement('div')
66
- chipWrap.className = 'dsh-tw-note-chips'
67
- const input = document.createElement('input')
68
- input.className = 'dsh-tw-note-taginput'
69
- input.placeholder = 'tag(可多选,自动补全)'
70
- const suggest = document.createElement('div')
71
- suggest.className = 'dsh-tw-note-tagsuggest'
72
- suggest.hidden = true
73
- wrap.append(chipWrap, input, suggest)
74
-
75
- const chips: string[] = []
76
- const hideSuggest = (): void => { suggest.hidden = true }
77
-
78
- const renderChips = (): void => {
79
- chipWrap.replaceChildren()
80
- for (const tag of chips) {
81
- const chip = document.createElement('span')
82
- chip.className = 'dsh-tw-note-tagchip'
83
- chip.textContent = tag
84
- const x = document.createElement('span')
85
- x.className = 'dsh-tw-note-tagchip-x'
86
- x.textContent = '×'
87
- x.title = `移除 tag「${tag}」`
88
- x.addEventListener('click', (event) => {
89
- event.stopPropagation()
90
- const i = chips.indexOf(tag)
91
- if (i >= 0) {
92
- chips.splice(i, 1)
93
- renderChips()
94
- }
95
- })
96
- chip.append(x)
97
- chipWrap.append(chip)
98
- }
99
- }
100
-
101
- const addTag = (tag: string): void => {
102
- const t = tag.trim()
103
- if (t.length === 0 || chips.includes(t)) return
104
- chips.push(t)
105
- input.value = ''
106
- renderChips()
107
- hideSuggest()
108
- input.focus()
109
- }
110
-
111
- const commitInput = (): void => {
112
- for (const raw of input.value.split(/\s+/)) addTag(raw)
113
- }
114
-
115
- // Existing tags, fetched lazily once per widget lifetime.
116
- let knownTags: string[] = []
117
- let tagsPromise: Promise<string[]> | undefined
118
- const ensureTags = (): Promise<string[]> => {
119
- tagsPromise ??= fetch(TAGS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
120
- .then((r) => (r.ok ? (r.json() as Promise<{ tags?: string[] }>) : Promise.resolve<{ tags?: string[] }>({})))
121
- .then((p) => [...(p.tags ?? [])].sort((a, b) => a.localeCompare(b, 'zh')))
122
- .catch(() => [])
123
- void tagsPromise.then((list) => { knownTags = list })
124
- return tagsPromise
125
- }
126
-
127
- const showSuggest = (): void => {
128
- const q = input.value.trim().toLowerCase()
129
- const matches = knownTags
130
- .filter((t) => !chips.includes(t) && (q.length === 0 || t.toLowerCase().includes(q)))
131
- .slice(0, 8)
132
- suggest.replaceChildren()
133
- for (const tag of matches) {
134
- const item = document.createElement('div')
135
- item.className = 'dsh-tw-note-tagsuggest-item'
136
- item.textContent = tag
137
- item.addEventListener('mousedown', (event) => {
138
- event.preventDefault()
139
- addTag(tag)
140
- })
141
- suggest.append(item)
142
- }
143
- suggest.hidden = matches.length === 0
144
- }
145
-
146
- input.addEventListener('focus', () => { void ensureTags().then(showSuggest) })
147
- input.addEventListener('input', () => {
148
- if (knownTags.length === 0) void ensureTags().then(showSuggest)
149
- else showSuggest()
150
- })
151
- input.addEventListener('keydown', (event) => {
152
- if (event.key === 'Enter' || event.key === ',') {
153
- event.preventDefault()
154
- commitInput()
155
- } else if (event.key === 'Backspace' && input.value.length === 0 && chips.length > 0) {
156
- chips.pop()
157
- renderChips()
158
- } else if (event.key === 'Escape') {
159
- hideSuggest()
160
- }
161
- })
162
- document.addEventListener('click', (event) => {
163
- if (!wrap.contains(event.target as Node)) hideSuggest()
164
- }, true)
165
-
166
- return {
167
- el: wrap,
168
- getTags: () => {
169
- commitInput()
170
- return [...chips]
171
- },
172
- setDefault: (tag: string) => {
173
- // Only pre-fill when nothing is chosen yet; never steal focus.
174
- if (chips.length === 0) {
175
- const t = tag.trim()
176
- if (t.length > 0) {
177
- chips.push(t)
178
- renderChips()
179
- }
180
- }
181
- },
182
- }
183
- }
184
-
185
- /** Build the whole widget DOM and wire it up (returns the appended root). */
186
- function buildWidget(): HTMLDivElement {
187
- const root = document.createElement('div')
188
- root.className = 'dsh-tw-note'
189
-
190
- const card = document.createElement('div')
191
- card.className = 'dsh-tw-note-card'
192
- card.hidden = true
193
-
194
- const head = document.createElement('div')
195
- head.className = 'dsh-tw-note-head'
196
- const label = document.createElement('span')
197
- label.className = 'dsh-tw-note-label'
198
- label.textContent = '📝 快速笔记'
199
- const closeBtn = document.createElement('button')
200
- closeBtn.type = 'button'
201
- closeBtn.className = 'dsh-tw-note-close'
202
- closeBtn.title = '收起'
203
- closeBtn.textContent = '✕'
204
- head.append(label, closeBtn)
205
-
206
- const fields = document.createElement('div')
207
- fields.className = 'dsh-tw-note-fields'
208
- const titleInput = document.createElement('input')
209
- titleInput.className = 'dsh-tw-note-title'
210
- titleInput.placeholder = '标题(默认时间戳)'
211
- const tagEditor = buildTagEditor()
212
- fields.append(titleInput, tagEditor.el)
213
-
214
- const textarea = document.createElement('textarea')
215
- textarea.className = 'dsh-tw-note-text'
216
- textarea.placeholder = '写点东西…(Ctrl+Enter 保存)'
217
-
218
- const foot = document.createElement('div')
219
- foot.className = 'dsh-tw-note-foot'
220
- const hint = document.createElement('span')
221
- hint.className = 'dsh-tw-note-hint'
222
- hint.textContent = 'Ctrl+Enter'
223
- const edit = document.createElement('button')
224
- edit.type = 'button'
225
- edit.className = 'dsh-tw-note-edit'
226
- edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
227
- edit.textContent = '✏️ 在 TW 中编辑'
228
- const save = document.createElement('button')
229
- save.type = 'button'
230
- save.className = 'dsh-tw-note-save'
231
- save.textContent = '保存'
232
- foot.append(hint, edit, save)
233
-
234
- card.append(head, fields, textarea, foot)
235
-
236
- const toggle = document.createElement('button')
237
- toggle.type = 'button'
238
- toggle.className = 'dsh-tw-note-toggle'
239
- toggle.textContent = '📝 快速笔记'
240
-
241
- root.append(card, toggle)
242
- document.body.append(root)
243
-
244
- let defaultTag = 'inbox'
245
- let opened = false
246
-
247
- const resetTitle = (): void => {
248
- titleInput.value = timestampTitle()
249
- }
250
-
251
- const open = async (): Promise<void> => {
252
- card.hidden = false
253
- opened = true
254
- resetTitle()
255
- textarea.focus()
256
- // Fetch the effective default tag once, then pre-fill if nothing is chosen.
257
- defaultTag = (await fetchUiOptions()).defaultTag
258
- tagEditor.setDefault(defaultTag)
259
- }
260
-
261
- const close = (): void => {
262
- card.hidden = true
263
- opened = false
264
- }
265
-
266
- toggle.addEventListener('click', () => { void (opened ? close() : open()) })
267
- closeBtn.addEventListener('click', close)
268
-
269
- const doSave = async (): Promise<void> => {
270
- const text = textarea.value.trim()
271
- if (text.length === 0) {
272
- toast('内容为空,未保存')
273
- return
274
- }
275
- save.disabled = true
276
- save.textContent = '保存中…'
277
- try {
278
- const res = await fetch(NOTE_ENDPOINT, {
279
- method: 'POST',
280
- headers: { 'content-type': 'application/json' },
281
- body: JSON.stringify({
282
- title: titleInput.value.trim(),
283
- tags: tagEditor.getTags(),
284
- text,
285
- }),
286
- signal: AbortSignal.timeout(10_000),
287
- })
288
- const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
289
- if (!res.ok || payload?.ok !== true) {
290
- toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
291
- return
292
- }
293
- textarea.value = ''
294
- resetTitle()
295
- toast(`已保存「${payload.title ?? titleInput.value}」`)
296
- close()
297
- } catch (err) {
298
- toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
299
- } finally {
300
- save.disabled = false
301
- save.textContent = '保存'
302
- }
303
- }
304
-
305
- save.addEventListener('click', () => { void doSave() })
306
- textarea.addEventListener('keydown', (event) => {
307
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
308
- event.preventDefault()
309
- void doSave()
310
- }
311
- })
312
-
313
- /** Save (if non-empty) and open the tiddler in TW's native editor. */
314
- const doEdit = async (): Promise<void> => {
315
- const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
316
- const text = textarea.value
317
- const tags = tagEditor.getTags()
318
- edit.disabled = true
319
- edit.textContent = '打开中…'
320
- try {
321
- const res = await fetch(EDIT_ENDPOINT, {
322
- method: 'POST',
323
- headers: { 'content-type': 'application/json' },
324
- body: JSON.stringify({ title, tags, text }),
325
- signal: AbortSignal.timeout(10_000),
326
- })
327
- const payload = (await res.json().catch(() => null)) as
328
- | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
329
- | null
330
- if (!res.ok || payload?.ok !== true) {
331
- toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
332
- return
333
- }
334
- if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
335
- toast('打开失败:服务未返回编辑器地址')
336
- return
337
- }
338
- openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
339
- toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
340
- } catch (err) {
341
- toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
342
- } finally {
343
- edit.disabled = false
344
- edit.textContent = '✏️ 在 TW 中编辑'
345
- }
346
- }
347
-
348
- edit.addEventListener('click', () => { void doEdit() })
349
-
350
- return root
351
- }
352
-
353
- /**
354
- * Mount the floating quick-note widget. Fetches /status first: when
355
- * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
356
- * Returns a disposer that removes it.
357
- */
358
- export function mountNoteWidget(): () => void {
359
- let disposed = false
360
- let root: HTMLDivElement | undefined
361
- const disposer = (): void => {
362
- disposed = true
363
- root?.remove()
364
- const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
365
- toastEl?.remove()
366
- }
367
- void (async () => {
368
- const ui = await fetchUiOptions()
369
- if (disposed || !ui.showQuickNote) return
370
- root = buildWidget()
371
- })()
372
- return disposer
373
- }
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
+ }