dsh-tiddlywiki 0.1.1 → 0.2.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.
@@ -3,9 +3,17 @@
3
3
  * collapsible, independent of any shell DOM. Lets the human jot drafts /
4
4
  * scratch notes while waiting for the AI or drafting the next prompt.
5
5
  *
6
- * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tag
6
+ * Save posts to /dsh-tiddlywiki/note → an independent tiddler (title & tags
7
7
  * editable; defaults: timestamp title + config tag, usually "inbox").
8
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
+ *
9
17
  * @module dsh-tiddlywiki/client/note-widget
10
18
  */
11
19
  import { toast } from './toast.ts'
@@ -14,6 +22,7 @@ import { openEditorPopup } from './editor-popup.ts'
14
22
  const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
15
23
  const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
16
24
  const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
25
+ const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
17
26
 
18
27
  function pad(n: number): string {
19
28
  return n < 10 ? `0${n}` : String(n)
@@ -24,18 +33,157 @@ function timestampTitle(date = new Date()): string {
24
33
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
25
34
  }
26
35
 
27
- async function fetchDefaultTag(): Promise<string> {
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' }
28
43
  try {
29
44
  const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
30
- if (!res.ok) return 'inbox'
31
- const payload = (await res.json()) as { note?: { tag?: string } }
32
- return payload.note?.tag ?? 'inbox'
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
+ }
33
51
  } catch {
34
- return 'inbox'
52
+ return fallback
35
53
  }
36
54
  }
37
55
 
38
- export function mountNoteWidget(): () => void {
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 {
39
187
  const root = document.createElement('div')
40
188
  root.className = 'dsh-tw-note'
41
189
 
@@ -60,10 +208,8 @@ export function mountNoteWidget(): () => void {
60
208
  const titleInput = document.createElement('input')
61
209
  titleInput.className = 'dsh-tw-note-title'
62
210
  titleInput.placeholder = '标题(默认时间戳)'
63
- const tagInput = document.createElement('input')
64
- tagInput.className = 'dsh-tw-note-tag'
65
- tagInput.placeholder = 'tag(默认 inbox)'
66
- fields.append(titleInput, tagInput)
211
+ const tagEditor = buildTagEditor()
212
+ fields.append(titleInput, tagEditor.el)
67
213
 
68
214
  const textarea = document.createElement('textarea')
69
215
  textarea.className = 'dsh-tw-note-text'
@@ -106,11 +252,10 @@ export function mountNoteWidget(): () => void {
106
252
  card.hidden = false
107
253
  opened = true
108
254
  resetTitle()
109
- if (tagInput.value.length === 0) tagInput.value = defaultTag
110
255
  textarea.focus()
111
- // Refresh the configured default tag on each open (cheap, best effort).
112
- defaultTag = await fetchDefaultTag()
113
- if (tagInput.value.length === 0) tagInput.value = defaultTag
256
+ // Fetch the effective default tag once, then pre-fill if nothing is chosen.
257
+ defaultTag = (await fetchUiOptions()).defaultTag
258
+ tagEditor.setDefault(defaultTag)
114
259
  }
115
260
 
116
261
  const close = (): void => {
@@ -135,7 +280,7 @@ export function mountNoteWidget(): () => void {
135
280
  headers: { 'content-type': 'application/json' },
136
281
  body: JSON.stringify({
137
282
  title: titleInput.value.trim(),
138
- tag: tagInput.value.trim(),
283
+ tags: tagEditor.getTags(),
139
284
  text,
140
285
  }),
141
286
  signal: AbortSignal.timeout(10_000),
@@ -169,14 +314,14 @@ export function mountNoteWidget(): () => void {
169
314
  const doEdit = async (): Promise<void> => {
170
315
  const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
171
316
  const text = textarea.value
172
- const tag = tagInput.value.trim()
317
+ const tags = tagEditor.getTags()
173
318
  edit.disabled = true
174
319
  edit.textContent = '打开中…'
175
320
  try {
176
321
  const res = await fetch(EDIT_ENDPOINT, {
177
322
  method: 'POST',
178
323
  headers: { 'content-type': 'application/json' },
179
- body: JSON.stringify({ title, tag, text }),
324
+ body: JSON.stringify({ title, tags, text }),
180
325
  signal: AbortSignal.timeout(10_000),
181
326
  })
182
327
  const payload = (await res.json().catch(() => null)) as
@@ -202,9 +347,27 @@ export function mountNoteWidget(): () => void {
202
347
 
203
348
  edit.addEventListener('click', () => { void doEdit() })
204
349
 
205
- return () => {
206
- root.remove()
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()
207
364
  const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
208
365
  toastEl?.remove()
209
366
  }
367
+ void (async () => {
368
+ const ui = await fetchUiOptions()
369
+ if (disposed || !ui.showQuickNote) return
370
+ root = buildWidget()
371
+ })()
372
+ return disposer
210
373
  }