dsh-tiddlywiki 0.16.21 → 0.16.23

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,334 @@
1
+ /**
2
+ * Shared TiddlyWiki iframe machinery for DSH side surfaces (v0.16.23).
3
+ *
4
+ * The center-column panel, the native right-sidebar tab (rightbar-tab.ts) and
5
+ * the DSH Better Sidebar tab (better-sidebar-tab.ts) all embed the SAME-ORIGIN
6
+ * TW proxy (`/dsh-tiddlywiki/tw/`) in an iframe and share the same lifecycle:
7
+ * lazy-load on first show, `/status` polling with restart/error states, DSH
8
+ * theme sync, reload-on-FAB-event, and tiddler-hash navigation. This module
9
+ * owns that machinery plus the two small shared bits of state the surfaces
10
+ * mirror (the tab chip label from `ui.tabLabel` and the tab icon).
11
+ *
12
+ * Link routing: each mounted TW surface registers its live frame controller
13
+ * here; panel.ts asks `openTiddlerInLiveTab` first, so a wiki link lands in a
14
+ * visible side TW tab (rightbar / better-sidebar) before falling back to the
15
+ * center overlay. Mutual exclusion (one TW client at a time) rides the
16
+ * `dsh-panel-activate` protocol — each surface dispatches its own panel name
17
+ * on becoming visible and closes itself when another name activates.
18
+ *
19
+ * @module dsh-tiddlywiki/client/tw-frame
20
+ */
21
+ import * as React from 'react'
22
+ import { STATUS_ENDPOINT } from './endpoints.ts'
23
+ import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
24
+
25
+ /** Cross-plugin activation event; detail is the activating panel name. */
26
+ export const ACTIVATE_EVENT = 'dsh-panel-activate'
27
+ /** The "知识库" FAB's reload event; side frames reload with the center one. */
28
+ export const PANEL_RELOAD_EVENT = 'dsh-tw-panel-reload'
29
+
30
+ const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
31
+
32
+ /** Tab chip / + menu / guide copy default (label refreshed from `/status` ui.tabLabel). */
33
+ let tabLabel = '知识库'
34
+
35
+ /** Update the shared surface label from the live config (ui.tabLabel). */
36
+ export function setTabLabel(label: string): void {
37
+ const trimmed = typeof label === 'string' ? label.trim() : ''
38
+ if (trimmed.length > 0) tabLabel = trimmed
39
+ }
40
+
41
+ /** Current shared surface label (ui.tabLabel, default 「知识库」). */
42
+ export function getTabLabel(): string {
43
+ return tabLabel
44
+ }
45
+
46
+ export interface StatusPayload {
47
+ ok?: boolean
48
+ status: string
49
+ url?: string
50
+ /** Same-origin TW proxy path (e.g. /dsh-tiddlywiki/tw/); the iframe base. */
51
+ twProxy?: string
52
+ wikiPath?: string
53
+ error?: string
54
+ ui?: { followDshTheme?: boolean; darkPalette?: string; tabLabel?: string }
55
+ }
56
+
57
+ export async function fetchStatus(): Promise<StatusPayload | null> {
58
+ try {
59
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(8_000) })
60
+ if (!res.ok) return null
61
+ return (await res.json()) as StatusPayload
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ async function requestRestart(): Promise<boolean> {
68
+ try {
69
+ const res = await fetch(RESTART_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(8_000) })
70
+ return res.ok
71
+ } catch {
72
+ return false
73
+ }
74
+ }
75
+
76
+ /** The shared surface glyph: a wiki page with a TiddlyWiki-style "T". */
77
+ export function TwTabIcon({ size = 16, className }: { size?: number; className?: string }): React.ReactElement {
78
+ return React.createElement(
79
+ 'svg',
80
+ { width: size, height: size, className, viewBox: '0 0 16 16', fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
81
+ React.createElement('path', { d: 'M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z' }),
82
+ React.createElement('path', { d: 'M6 6h4M6 8.5h2.5' }),
83
+ )
84
+ }
85
+
86
+ /** The plain-DOM TW frame controller owned by one surface body. */
87
+ export interface TwFrameController {
88
+ /** Reflect the surface's visibility; loads lazily on the first show. */
89
+ setVisible(visible: boolean): void
90
+ isVisible(): boolean
91
+ /** Open a tiddler by title; false when this controller cannot serve it. */
92
+ openTiddler(title: string): boolean
93
+ dispose(): void
94
+ }
95
+
96
+ /** Live visible-capable TW frames; a controller joins on creation, leaves on dispose. */
97
+ const liveFrames = new Set<TwFrameController>()
98
+
99
+ /**
100
+ * Ask every live side TW surface to open a tiddler; true when one served it.
101
+ * A controller answers false while hidden or disposed, so the center overlay
102
+ * only opens when no visible side TW tab can take the link.
103
+ */
104
+ export function openTiddlerInLiveTab(title: string): boolean {
105
+ for (const controller of liveFrames) {
106
+ if (controller.openTiddler(title)) return true
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Refresh the shared surface label from `/status` (ui.tabLabel) so a surface
113
+ * that mounts before the first status call already shows the right name.
114
+ */
115
+ export function warmTabLabel(): void {
116
+ void fetchStatus().then((payload) => {
117
+ if (payload?.ui !== undefined) setTabLabel(payload.ui.tabLabel ?? tabLabel)
118
+ })
119
+ }
120
+
121
+ /**
122
+ * Create a TW frame controller inside `host`. The frame loads lazily on the
123
+ * first `setVisible(true)`, polls `/status` (starting/restart/error states),
124
+ * follows the DSH theme, reloads on the FAB's reload event, and navigates to
125
+ * `#<title>` hashes once the frame is ready. `signal` (the tab's abort) tears
126
+ * the whole controller down. Joins the live-frame registry on creation and
127
+ * leaves it on dispose.
128
+ */
129
+ export function createTwFrameController(host: HTMLElement, signal: AbortSignal): TwFrameController {
130
+ let visible = false
131
+ let started = false
132
+ let disposed = false
133
+ let refreshTimer: number | undefined
134
+ let refreshAttempts = 0
135
+ let frameLoaded = false
136
+ let pendingHash: string | null = null
137
+ let themeSyncDispose: (() => void) | undefined
138
+
139
+ const view = document.createElement('div')
140
+ view.className = 'dsh-tw-rightbar-view'
141
+
142
+ const frameArea = document.createElement('div')
143
+ frameArea.className = 'dsh-tw-rightbar-frame-wrap'
144
+ const frame = document.createElement('iframe')
145
+ frame.className = 'dsh-tw-rightbar-frame'
146
+ frame.title = 'TiddlyWiki'
147
+ frame.hidden = true
148
+ frameArea.append(frame)
149
+
150
+ const errorArea = document.createElement('div')
151
+ errorArea.className = 'dsh-tw-rightbar-error'
152
+ errorArea.hidden = true
153
+
154
+ view.append(frameArea, errorArea)
155
+ host.append(view)
156
+
157
+ frame.addEventListener('load', () => {
158
+ frameLoaded = true
159
+ applyPendingHash()
160
+ })
161
+ themeSyncDispose = attachThemeSync(frame)
162
+
163
+ const showError = (message: string): void => {
164
+ frame.hidden = true
165
+ errorArea.hidden = false
166
+ errorArea.replaceChildren()
167
+ const p = document.createElement('div')
168
+ p.textContent = 'TiddlyWiki 服务不可用'
169
+ const code = document.createElement('code')
170
+ code.textContent = message
171
+ const retry = document.createElement('button')
172
+ retry.type = 'button'
173
+ retry.textContent = '重试'
174
+ retry.addEventListener('click', () => {
175
+ retry.disabled = true
176
+ retry.textContent = '重启中…'
177
+ void requestRestart().finally(() => { void doRefresh() })
178
+ })
179
+ errorArea.append(p, code, retry)
180
+ }
181
+
182
+ const showStarting = (): void => {
183
+ frame.hidden = true
184
+ errorArea.hidden = false
185
+ errorArea.replaceChildren()
186
+ const p = document.createElement('div')
187
+ p.textContent = 'TiddlyWiki 服务正在启动…'
188
+ errorArea.append(p)
189
+ }
190
+
191
+ const showFrame = (url: string): void => {
192
+ errorArea.hidden = true
193
+ // Only reveal the frame while the surface is on screen.
194
+ frame.hidden = !visible
195
+ // Set the src only when the url changed, so an editor never loses unsaved
196
+ // state on a status refresh.
197
+ if (frame.dataset.loaded !== url) {
198
+ frame.dataset.loaded = url
199
+ frameLoaded = false
200
+ frame.src = url
201
+ }
202
+ }
203
+
204
+ /** Apply a pending tiddler-hash once the frame is ready (see panel.ts). */
205
+ const applyPendingHash = (): void => {
206
+ if (pendingHash === null || !frameLoaded) return
207
+ const hash = pendingHash
208
+ const win = frame.contentWindow
209
+ if (win === null) {
210
+ fallbackLoad(hash)
211
+ return
212
+ }
213
+ const tryOnce = (attempt: number): void => {
214
+ if (disposed) return
215
+ if (pendingHash !== hash) return
216
+ const frameTw = win as { $tw?: unknown }
217
+ if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
218
+ if (attempt < 40) {
219
+ window.setTimeout(() => tryOnce(attempt + 1), 150)
220
+ return
221
+ }
222
+ fallbackLoad(hash)
223
+ return
224
+ }
225
+ pendingHash = null
226
+ try {
227
+ if (win.location.hash !== hash) win.location.hash = hash
228
+ } catch {
229
+ fallbackLoad(hash)
230
+ }
231
+ }
232
+ tryOnce(0)
233
+ }
234
+
235
+ const fallbackLoad = (hash: string): void => {
236
+ if (disposed) return
237
+ if (pendingHash === hash) pendingHash = null
238
+ const base = frame.src.split('#')[0]
239
+ if (frame.src !== `${base}${hash}`) frame.src = `${base}${hash}`
240
+ }
241
+
242
+ const doRefresh = async (): Promise<void> => {
243
+ if (refreshTimer !== undefined) {
244
+ window.clearTimeout(refreshTimer)
245
+ refreshTimer = undefined
246
+ }
247
+ const payload = await fetchStatus()
248
+ if (disposed) return
249
+ if (payload === null) {
250
+ showError('无法访问 /dsh-tiddlywiki/status')
251
+ return
252
+ }
253
+ if (payload.ui !== undefined) {
254
+ setTabLabel(payload.ui.tabLabel ?? tabLabel)
255
+ setThemeSyncConfig({
256
+ enabled: payload.ui.followDshTheme !== false,
257
+ darkPalette: payload.ui.darkPalette,
258
+ })
259
+ }
260
+ if (payload.status === 'running') {
261
+ refreshAttempts = 0
262
+ if (typeof payload.twProxy === 'string') {
263
+ showFrame(new URL(payload.twProxy, window.location.origin).href)
264
+ } else if (typeof payload.url === 'string') {
265
+ showFrame(payload.url)
266
+ } else {
267
+ showError('服务未返回编辑器地址')
268
+ }
269
+ return
270
+ }
271
+ if (payload.status === 'starting') {
272
+ showStarting()
273
+ if (refreshAttempts < 30) {
274
+ refreshAttempts++
275
+ refreshTimer = window.setTimeout(() => { void doRefresh() }, 1_500)
276
+ }
277
+ return
278
+ }
279
+ refreshAttempts = 0
280
+ showError(payload.error ?? `服务状态:${payload.status}`)
281
+ }
282
+
283
+ const onReloadRequest = (): void => {
284
+ if (!frame.hidden) frame.src = frame.src
285
+ }
286
+ document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
287
+
288
+ const onAbort = (): void => controller.dispose()
289
+ signal.addEventListener('abort', onAbort, { once: true })
290
+
291
+ const controller: TwFrameController = {
292
+ setVisible(next: boolean): void {
293
+ visible = next
294
+ if (!started) {
295
+ // Nothing loaded yet: stay hidden until the first show kicks the load.
296
+ frame.hidden = true
297
+ view.dataset.visible = next ? '1' : '0'
298
+ if (next) {
299
+ started = true
300
+ void doRefresh()
301
+ }
302
+ return
303
+ }
304
+ frame.hidden = !next
305
+ view.dataset.visible = next ? '1' : '0'
306
+ },
307
+ isVisible(): boolean {
308
+ return visible
309
+ },
310
+ openTiddler(title: string): boolean {
311
+ if (disposed || !visible) return false
312
+ pendingHash = `#${encodeURIComponent(title)}`
313
+ if (!started) {
314
+ started = true
315
+ void doRefresh()
316
+ } else {
317
+ applyPendingHash()
318
+ }
319
+ return true
320
+ },
321
+ dispose(): void {
322
+ if (disposed) return
323
+ disposed = true
324
+ liveFrames.delete(controller)
325
+ document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
326
+ signal.removeEventListener('abort', onAbort)
327
+ if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
328
+ themeSyncDispose?.()
329
+ view.remove()
330
+ },
331
+ }
332
+ liveFrames.add(controller)
333
+ return controller
334
+ }
@@ -26,9 +26,11 @@ export interface UiConfig {
26
26
  showSessionTab: boolean
27
27
  /** 是否在 DSH 右侧边栏提供 TiddlyWiki 入口/Tab(默认 true)。 */
28
28
  showRightbarTab: boolean
29
+ /** 是否在 DSH Better Sidebar 侧边栏注册 TiddlyWiki tab(默认 true)。 */
30
+ showBetterSidebarTab: boolean
29
31
  }
30
32
 
31
- const FALLBACK: UiConfig = { showQuickNoteDock: true, quickNoteMode: 'native', sidebarLabel: 'TiddlyWiki', tabLabel: '知识库', showSessionTab: true, showRightbarTab: true }
33
+ const FALLBACK: UiConfig = { showQuickNoteDock: true, quickNoteMode: 'native', sidebarLabel: 'TiddlyWiki', tabLabel: '知识库', showSessionTab: true, showRightbarTab: true, showBetterSidebarTab: true }
32
34
 
33
35
  const CACHE_TTL_MS = 15_000
34
36
  let cache: { at: number; value: UiConfig } | undefined
@@ -43,7 +45,7 @@ export async function fetchUiConfig(opts: { force?: boolean } = {}): Promise<UiC
43
45
  try {
44
46
  const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
45
47
  if (!res.ok) return FALLBACK
46
- const p = (await res.json()) as { ui?: { showQuickNoteDock?: boolean; quickNoteMode?: 'native' | 'card'; sidebarLabel?: string; tabLabel?: string; showSessionTab?: boolean; showRightbarTab?: boolean } }
48
+ const p = (await res.json()) as { ui?: { showQuickNoteDock?: boolean; quickNoteMode?: 'native' | 'card'; sidebarLabel?: string; tabLabel?: string; showSessionTab?: boolean; showRightbarTab?: boolean; showBetterSidebarTab?: boolean } }
47
49
  const value: UiConfig = {
48
50
  showQuickNoteDock: p.ui?.showQuickNoteDock !== false,
49
51
  quickNoteMode: p.ui?.quickNoteMode === 'card' ? 'card' : 'native',
@@ -55,6 +57,7 @@ export async function fetchUiConfig(opts: { force?: boolean } = {}): Promise<UiC
55
57
  : '知识库',
56
58
  showSessionTab: p.ui?.showSessionTab !== false,
57
59
  showRightbarTab: p.ui?.showRightbarTab !== false,
60
+ showBetterSidebarTab: p.ui?.showBetterSidebarTab !== false,
58
61
  }
59
62
  cache = { at: Date.now(), value }
60
63
  return value
@@ -88,6 +88,14 @@ export interface PluginConfigShape {
88
88
  * DSH 无 rightbar 时该配置不生效(客户端自动跳过)。
89
89
  */
90
90
  showRightbarTab?: boolean
91
+ /**
92
+ * 是否在 DSH Better Sidebar(dsh-better-sidebar 插件)侧边栏提供
93
+ * TiddlyWiki tab(默认 true):通过其 client 服务 `ctx.betterSidebar`
94
+ * 注册 tab 类型,+ 菜单可打开完整 TW 编辑器(与聊天并排)。未安装
95
+ * Better Sidebar 时该配置不生效(客户端自动跳过);该插件自己的设置页
96
+ * 也为本 tab 提供独立的启用开关。
97
+ */
98
+ showBetterSidebarTab?: boolean
91
99
  }
92
100
  uiLanguage?: string
93
101
  [key: string]: unknown
@@ -153,6 +153,8 @@ export interface UiDefaultsPublic {
153
153
  showSessionTab: boolean
154
154
  /** 是否在 DSH 右侧边栏提供 TiddlyWiki 入口/Tab(默认 true)。 */
155
155
  showRightbarTab: boolean
156
+ /** 是否在 DSH Better Sidebar 侧边栏注册 TiddlyWiki tab(默认 true)。 */
157
+ showBetterSidebarTab: boolean
156
158
  }
157
159
 
158
160
  export interface RouteDeps {