dsh-tiddlywiki 0.1.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.
@@ -0,0 +1,210 @@
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 & tag
7
+ * editable; defaults: timestamp title + config tag, usually "inbox").
8
+ *
9
+ * @module dsh-tiddlywiki/client/note-widget
10
+ */
11
+ import { toast } from './toast.ts'
12
+ import { openEditorPopup } from './editor-popup.ts'
13
+
14
+ const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
15
+ const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
16
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
17
+
18
+ function pad(n: number): string {
19
+ return n < 10 ? `0${n}` : String(n)
20
+ }
21
+
22
+ /** Default note title: `YYYY-MM-DD HH:mm`. */
23
+ function timestampTitle(date = new Date()): string {
24
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
25
+ }
26
+
27
+ async function fetchDefaultTag(): Promise<string> {
28
+ try {
29
+ 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'
33
+ } catch {
34
+ return 'inbox'
35
+ }
36
+ }
37
+
38
+ export function mountNoteWidget(): () => void {
39
+ const root = document.createElement('div')
40
+ root.className = 'dsh-tw-note'
41
+
42
+ const card = document.createElement('div')
43
+ card.className = 'dsh-tw-note-card'
44
+ card.hidden = true
45
+
46
+ const head = document.createElement('div')
47
+ head.className = 'dsh-tw-note-head'
48
+ const label = document.createElement('span')
49
+ label.className = 'dsh-tw-note-label'
50
+ label.textContent = '📝 快速笔记'
51
+ const closeBtn = document.createElement('button')
52
+ closeBtn.type = 'button'
53
+ closeBtn.className = 'dsh-tw-note-close'
54
+ closeBtn.title = '收起'
55
+ closeBtn.textContent = '✕'
56
+ head.append(label, closeBtn)
57
+
58
+ const fields = document.createElement('div')
59
+ fields.className = 'dsh-tw-note-fields'
60
+ const titleInput = document.createElement('input')
61
+ titleInput.className = 'dsh-tw-note-title'
62
+ 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)
67
+
68
+ const textarea = document.createElement('textarea')
69
+ textarea.className = 'dsh-tw-note-text'
70
+ textarea.placeholder = '写点东西…(Ctrl+Enter 保存)'
71
+
72
+ const foot = document.createElement('div')
73
+ foot.className = 'dsh-tw-note-foot'
74
+ const hint = document.createElement('span')
75
+ hint.className = 'dsh-tw-note-hint'
76
+ hint.textContent = 'Ctrl+Enter'
77
+ const edit = document.createElement('button')
78
+ edit.type = 'button'
79
+ edit.className = 'dsh-tw-note-edit'
80
+ edit.title = '保存并在 TiddlyWiki 原生编辑器中打开'
81
+ edit.textContent = '✏️ 在 TW 中编辑'
82
+ const save = document.createElement('button')
83
+ save.type = 'button'
84
+ save.className = 'dsh-tw-note-save'
85
+ save.textContent = '保存'
86
+ foot.append(hint, edit, save)
87
+
88
+ card.append(head, fields, textarea, foot)
89
+
90
+ const toggle = document.createElement('button')
91
+ toggle.type = 'button'
92
+ toggle.className = 'dsh-tw-note-toggle'
93
+ toggle.textContent = '📝 快速笔记'
94
+
95
+ root.append(card, toggle)
96
+ document.body.append(root)
97
+
98
+ let defaultTag = 'inbox'
99
+ let opened = false
100
+
101
+ const resetTitle = (): void => {
102
+ titleInput.value = timestampTitle()
103
+ }
104
+
105
+ const open = async (): Promise<void> => {
106
+ card.hidden = false
107
+ opened = true
108
+ resetTitle()
109
+ if (tagInput.value.length === 0) tagInput.value = defaultTag
110
+ 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
114
+ }
115
+
116
+ const close = (): void => {
117
+ card.hidden = true
118
+ opened = false
119
+ }
120
+
121
+ toggle.addEventListener('click', () => { void (opened ? close() : open()) })
122
+ closeBtn.addEventListener('click', close)
123
+
124
+ const doSave = async (): Promise<void> => {
125
+ const text = textarea.value.trim()
126
+ if (text.length === 0) {
127
+ toast('内容为空,未保存')
128
+ return
129
+ }
130
+ save.disabled = true
131
+ save.textContent = '保存中…'
132
+ try {
133
+ const res = await fetch(NOTE_ENDPOINT, {
134
+ method: 'POST',
135
+ headers: { 'content-type': 'application/json' },
136
+ body: JSON.stringify({
137
+ title: titleInput.value.trim(),
138
+ tag: tagInput.value.trim(),
139
+ text,
140
+ }),
141
+ signal: AbortSignal.timeout(10_000),
142
+ })
143
+ const payload = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
144
+ if (!res.ok || payload?.ok !== true) {
145
+ toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
146
+ return
147
+ }
148
+ textarea.value = ''
149
+ resetTitle()
150
+ toast(`已保存「${payload.title ?? titleInput.value}」`)
151
+ close()
152
+ } catch (err) {
153
+ toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
154
+ } finally {
155
+ save.disabled = false
156
+ save.textContent = '保存'
157
+ }
158
+ }
159
+
160
+ save.addEventListener('click', () => { void doSave() })
161
+ textarea.addEventListener('keydown', (event) => {
162
+ if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
163
+ event.preventDefault()
164
+ void doSave()
165
+ }
166
+ })
167
+
168
+ /** Save (if non-empty) and open the tiddler in TW's native editor. */
169
+ const doEdit = async (): Promise<void> => {
170
+ const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
171
+ const text = textarea.value
172
+ const tag = tagInput.value.trim()
173
+ edit.disabled = true
174
+ edit.textContent = '打开中…'
175
+ try {
176
+ const res = await fetch(EDIT_ENDPOINT, {
177
+ method: 'POST',
178
+ headers: { 'content-type': 'application/json' },
179
+ body: JSON.stringify({ title, tag, text }),
180
+ signal: AbortSignal.timeout(10_000),
181
+ })
182
+ const payload = (await res.json().catch(() => null)) as
183
+ | { ok?: boolean; title?: string; draftTitle?: string; twUrl?: string; error?: string }
184
+ | null
185
+ if (!res.ok || payload?.ok !== true) {
186
+ toast(`打开失败:${payload?.error ?? `HTTP ${res.status}`}`)
187
+ return
188
+ }
189
+ if (typeof payload.twUrl !== 'string' || typeof payload.draftTitle !== 'string') {
190
+ toast('打开失败:服务未返回编辑器地址')
191
+ return
192
+ }
193
+ openEditorPopup(`${payload.twUrl}#${encodeURIComponent(payload.draftTitle)}`, payload.title ?? title)
194
+ toast(`已在弹出窗口打开「${payload.title ?? title}」编辑器`)
195
+ } catch (err) {
196
+ toast(`打开失败:${err instanceof Error ? err.message : String(err)}`)
197
+ } finally {
198
+ edit.disabled = false
199
+ edit.textContent = '✏️ 在 TW 中编辑'
200
+ }
201
+ }
202
+
203
+ edit.addEventListener('click', () => { void doEdit() })
204
+
205
+ return () => {
206
+ root.remove()
207
+ const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
208
+ toastEl?.remove()
209
+ }
210
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Center-column TiddlyWiki editor panel (design doc §12).
3
+ *
4
+ * A fixed-position overlay sized to the CENTER column's bounding rect and
5
+ * appended to document.body, so it never depends on the shell's flex/grid
6
+ * height model or on the column being a positioning ancestor (both caused the
7
+ * earlier "half-height, scrollable" symptom). The rect is re-measured on
8
+ * resize, on layout mutations, and on a light interval. While active it hides
9
+ * the conversation content via a stylesheet rule keyed on `data-dsh-tw-active`.
10
+ * Toggling rides the shared PanelState; cross-plugin exclusivity rides the
11
+ * `dsh-panel-activate` event (same protocol as dsh-taskboard).
12
+ *
13
+ * The iframe points DIRECTLY at the TW service (http://127.0.0.1:<port>),
14
+ * bypassing reverse-proxy sensitivity (design doc R1 — proxy is only a
15
+ * fallback). The panel reads /status first and only sets iframe.src when the
16
+ * service is actually running.
17
+ *
18
+ * @module dsh-tiddlywiki/client/panel
19
+ */
20
+ import type { PanelState } from './state.ts'
21
+ import { ENTRY_SELECTOR } from './sidebar-entry.ts'
22
+
23
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-tw-view]'
24
+
25
+ /**
26
+ * Center-column targets, most-specific shell generation first. The official
27
+ * layout shell (dsh-client-ui-layout) drops data-pane and uses a CSS-Module
28
+ * hashed `centerCol`; older shells put `data-pane="conversation"` on the same
29
+ * full-height grid item; DSH Desktop exposes the non-compat
30
+ * `.dshDesktopConversationSurface`.
31
+ */
32
+ const COLUMN_SELECTORS = ['[class*="centerCol"]', '[data-pane="conversation"]', '.dshDesktopConversationSurface']
33
+
34
+ const ACTIVE_ATTR = 'data-dsh-tw-active'
35
+ /** Sibling panels' activation attributes, evicted when this panel opens. */
36
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-atb-active', 'data-dsh-taskboard-active', 'data-dsh-ssh-active']
37
+ /** Cross-plugin activation event; detail is the activating panel name. */
38
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
39
+ const PANEL_NAME = 'dsh-tiddlywiki'
40
+
41
+ /** Overlay z-index: above the shell content, below the note widget (950). */
42
+ const PANEL_Z_INDEX = 40
43
+ /** Safety re-measure cadence for shell layout changes CSS can't see. */
44
+ const SYNC_INTERVAL_MS = 2_000
45
+
46
+ const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
47
+ const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
48
+
49
+ interface StatusPayload {
50
+ ok?: boolean
51
+ status: string
52
+ url?: string
53
+ wikiPath?: string
54
+ error?: string
55
+ note?: { tag?: string }
56
+ }
57
+
58
+ function conversationColumn(): HTMLElement | undefined {
59
+ for (const selector of COLUMN_SELECTORS) {
60
+ const el = document.querySelector<HTMLElement>(selector)
61
+ if (el !== null) return el
62
+ }
63
+ return undefined
64
+ }
65
+
66
+ async function fetchStatus(): Promise<StatusPayload | null> {
67
+ try {
68
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
69
+ if (!res.ok) return null
70
+ return (await res.json()) as StatusPayload
71
+ } catch {
72
+ return null
73
+ }
74
+ }
75
+
76
+ async function requestRestart(): Promise<boolean> {
77
+ try {
78
+ const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
79
+ return res.ok
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ export function mountPanel(state: PanelState): () => void {
86
+ let container: HTMLDivElement | undefined
87
+ let columnEl: HTMLElement | undefined
88
+ let iframe: HTMLIFrameElement | undefined
89
+ let frameArea: HTMLDivElement | undefined
90
+ let errorArea: HTMLDivElement | undefined
91
+ let chip: HTMLSpanElement | undefined
92
+ let refreshTimer: number | undefined
93
+ let refreshAttempts = 0
94
+
95
+ const build = (): HTMLDivElement => {
96
+ const view = document.createElement('div')
97
+ view.dataset.dshTwView = ''
98
+ view.className = 'dsh-tw-view'
99
+
100
+ const bar = document.createElement('div')
101
+ bar.className = 'dsh-tw-panel-bar'
102
+ const title = document.createElement('span')
103
+ title.className = 'dsh-tw-panel-title'
104
+ title.textContent = 'TiddlyWiki 知识库'
105
+ chip = document.createElement('span')
106
+ chip.className = 'dsh-tw-status-chip'
107
+ chip.dataset.state = 'unknown'
108
+ chip.textContent = '—'
109
+ const reload = document.createElement('button')
110
+ reload.type = 'button'
111
+ reload.textContent = '重载'
112
+ reload.addEventListener('click', () => {
113
+ if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
114
+ })
115
+ const refresh = document.createElement('button')
116
+ refresh.type = 'button'
117
+ refresh.textContent = '状态'
118
+ refresh.addEventListener('click', () => { void doRefresh() })
119
+ bar.append(title, chip, refresh, reload)
120
+
121
+ frameArea = document.createElement('div')
122
+ frameArea.className = 'dsh-tw-panel-frame-wrap'
123
+ frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
124
+ iframe = document.createElement('iframe')
125
+ iframe.className = 'dsh-tw-panel-frame'
126
+ iframe.title = 'TiddlyWiki'
127
+ iframe.hidden = true
128
+ frameArea.append(iframe)
129
+
130
+ errorArea = document.createElement('div')
131
+ errorArea.className = 'dsh-tw-panel-error'
132
+ errorArea.hidden = true
133
+
134
+ view.append(bar, frameArea, errorArea)
135
+ return view
136
+ }
137
+
138
+ const setChip = (stateName: string, text: string): void => {
139
+ if (chip === undefined) return
140
+ chip.dataset.state = stateName
141
+ chip.textContent = text
142
+ }
143
+
144
+ /** Pin the overlay to the center column's current viewport rect. */
145
+ const syncRect = (): void => {
146
+ if (container === undefined || columnEl === undefined) return
147
+ const rect = columnEl.getBoundingClientRect()
148
+ if (rect.width === 0 && rect.height === 0) return
149
+ const left = `${rect.left}px`
150
+ const top = `${rect.top}px`
151
+ const width = `${rect.width}px`
152
+ const height = `${rect.height}px`
153
+ if (container.style.left !== left) container.style.left = left
154
+ if (container.style.top !== top) container.style.top = top
155
+ if (container.style.width !== width) container.style.width = width
156
+ if (container.style.height !== height) container.style.height = height
157
+ }
158
+
159
+ const ensure = (): void => {
160
+ if (container !== undefined) return
161
+ columnEl = conversationColumn()
162
+ if (columnEl === undefined) return
163
+ container = build()
164
+ container.style.position = 'fixed'
165
+ container.style.zIndex = String(PANEL_Z_INDEX)
166
+ document.body.append(container)
167
+ syncRect()
168
+ }
169
+
170
+ const showError = (message: string): void => {
171
+ if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
172
+ iframe.hidden = true
173
+ errorArea.hidden = false
174
+ errorArea.textContent = ''
175
+ const p = document.createElement('div')
176
+ p.textContent = 'TiddlyWiki 服务不可用'
177
+ const code = document.createElement('code')
178
+ code.textContent = message
179
+ const retry = document.createElement('button')
180
+ retry.type = 'button'
181
+ retry.textContent = '重试'
182
+ retry.addEventListener('click', () => {
183
+ retry.disabled = true
184
+ retry.textContent = '重启中…'
185
+ void requestRestart().finally(() => { void doRefresh() })
186
+ })
187
+ errorArea.append(p, code, retry)
188
+ }
189
+
190
+ const showStarting = (): void => {
191
+ if (iframe === undefined || errorArea === undefined || frameArea === undefined) return
192
+ iframe.hidden = true
193
+ errorArea.hidden = false
194
+ errorArea.textContent = ''
195
+ const p = document.createElement('div')
196
+ p.textContent = 'TiddlyWiki 服务正在启动…'
197
+ errorArea.append(p)
198
+ }
199
+
200
+ const showFrame = (url: string): void => {
201
+ if (iframe === undefined || errorArea === undefined) return
202
+ errorArea.hidden = true
203
+ iframe.hidden = false
204
+ // Set the src only when the url actually changed, so an editor in the
205
+ // iframe never loses unsaved state on a status refresh.
206
+ if (iframe.dataset.loaded !== url) {
207
+ iframe.dataset.loaded = url
208
+ iframe.src = url
209
+ }
210
+ }
211
+
212
+ const doRefresh = async (): Promise<void> => {
213
+ if (refreshTimer !== undefined) {
214
+ window.clearTimeout(refreshTimer)
215
+ refreshTimer = undefined
216
+ }
217
+ const payload = await fetchStatus()
218
+ if (payload === null) {
219
+ setChip('failed', '状态不可达')
220
+ showError('无法访问 /dsh-tiddlywiki/status')
221
+ return
222
+ }
223
+ if (payload.status === 'running' && typeof payload.url === 'string') {
224
+ setChip('running', '在线')
225
+ refreshAttempts = 0
226
+ showFrame(payload.url)
227
+ return
228
+ }
229
+ if (payload.status === 'starting') {
230
+ setChip('starting', '启动中')
231
+ showStarting()
232
+ // Auto-poll while starting (bounded).
233
+ if (refreshAttempts < 30) {
234
+ refreshAttempts++
235
+ refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
236
+ }
237
+ return
238
+ }
239
+ setChip('failed', '离线')
240
+ refreshAttempts = 0
241
+ showError(payload.error ?? `服务状态:${payload.status}`)
242
+ }
243
+
244
+ const applyActive = (): void => {
245
+ if (state.isOpen()) {
246
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
247
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
248
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
249
+ void doRefresh()
250
+ } else {
251
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
252
+ if (refreshTimer !== undefined) {
253
+ window.clearTimeout(refreshTimer)
254
+ refreshTimer = undefined
255
+ }
256
+ refreshAttempts = 0
257
+ }
258
+ }
259
+ const onOtherActivate = (event: Event): void => {
260
+ const detail = (event as CustomEvent).detail
261
+ if (detail !== PANEL_NAME && state.isOpen()) state.closePanel()
262
+ }
263
+ const onClickSidebarRow = (event: MouseEvent): void => {
264
+ if (!state.isOpen()) return
265
+ const target = event.target as HTMLElement | null
266
+ if (target === null) return
267
+ if (target.closest(ENTRY_SELECTOR) !== null) return
268
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
269
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) state.closePanel()
270
+ }
271
+
272
+ // Mount the container once the column exists; self-heal on re-renders.
273
+ const waitObserver = new MutationObserver(() => { ensure() })
274
+ waitObserver.observe(document.body, { childList: true, subtree: true })
275
+
276
+ // Keep the overlay pinned to the column: resize, layout mutations, scroll.
277
+ const resizeObserver = new ResizeObserver(() => syncRect())
278
+ resizeObserver.observe(document.body)
279
+ const syncInterval = window.setInterval(syncRect, SYNC_INTERVAL_MS)
280
+ const onWindowResize = (): void => syncRect()
281
+ window.addEventListener('resize', onWindowResize)
282
+ const onAnyScroll = (): void => syncRect()
283
+ window.addEventListener('scroll', onAnyScroll, true)
284
+
285
+ document.addEventListener('click', onClickSidebarRow, true)
286
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
287
+ const unsubscribe = state.subscribe(applyActive)
288
+ ensure()
289
+ applyActive()
290
+
291
+ return () => {
292
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
293
+ window.clearInterval(syncInterval)
294
+ window.removeEventListener('resize', onWindowResize)
295
+ window.removeEventListener('scroll', onAnyScroll, true)
296
+ resizeObserver.disconnect()
297
+ document.removeEventListener('click', onClickSidebarRow, true)
298
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
299
+ waitObserver.disconnect()
300
+ unsubscribe()
301
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
302
+ container?.remove()
303
+ }
304
+ }