dsh-tiddlywiki 0.22.3 → 0.22.4

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.
@@ -6,8 +6,16 @@
6
6
  * TW proxy (`/dsh-tiddlywiki/tw/`) in an iframe and share the same lifecycle:
7
7
  * lazy-load on first show, `/status` polling with restart/error states, DSH
8
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).
9
+ * owns that machinery — `createTwFrameSurface()` is the ONE implementation —
10
+ * plus the two small shared bits of state the surfaces mirror (the tab chip
11
+ * label from `ui.tabLabel` and the tab icon).
12
+ *
13
+ * v0.22.4 convergence: `panel.ts` used to carry its own copies of
14
+ * showError/showStarting/showFrame/fallbackLoad/doRefresh. They drifted, and
15
+ * the drift is exactly what produced the v0.22.3 empty-`src` bug (one copy got
16
+ * the guard, the other did not). A surface now supplies only its SKIN (class
17
+ * names), its lazy build point and its visibility; every lifecycle decision
18
+ * lives here. Add new lifecycle behaviour in this file, never at a call site.
11
19
  *
12
20
  * Link routing: each mounted TW surface registers its live frame controller
13
21
  * here; panel.ts asks `openTiddlerInLiveTab` first, so a wiki link lands in a
@@ -78,77 +86,67 @@ export interface FrameLoadedState {
78
86
  * attribute was never assigned the property returns the EMBEDDING page's URL,
79
87
  * and assigning it back to `src` makes the DSH GUI load itself inside the
80
88
  * iframe (a second DSH instance: duplicate FAB, duplicated global listeners,
81
- * blank white page). Both callers below drive a full reload, so they must
82
- * refuse to touch a frame that has no loaded TW URL.
89
+ * blank white page). Every full-reload path must refuse to touch a frame that
90
+ * has no loaded TW URL.
83
91
  */
84
92
  export function loadableFrameUrl(dataset: FrameLoadedState): string | null {
85
93
  const loaded = dataset.loaded
86
94
  return loaded === undefined || loaded.length === 0 ? null : loaded
87
95
  }
88
96
 
89
- /** The plain-DOM TW frame controller owned by one surface body. */
90
- export interface TwFrameController {
91
- /** Reflect the surface's visibility; loads lazily on the first show. */
92
- setVisible(visible: boolean): void
93
- /** Open a tiddler by title; false when this controller cannot serve it. */
94
- openTiddler(title: string): boolean
95
- dispose(): void
96
- }
97
-
98
- /**
99
- * Controller for an ALREADY-aborted signal: no DOM is ever built, so there is
100
- * nothing to show and nothing to reap (the abort listener would never fire).
101
- */
102
- function disposedController(): TwFrameController {
103
- return {
104
- setVisible(): void {},
105
- openTiddler(): boolean { return false },
106
- dispose(): void {},
107
- }
108
- }
109
-
110
- /** Live visible-capable TW frames; a controller joins on creation, leaves on dispose. */
111
- const liveFrames = new Set<TwFrameController>()
97
+ /* ────────────────────────── the shared frame kernel ─────────────────────── */
112
98
 
113
- /**
114
- * Ask every live side TW surface to open a tiddler; true when one served it.
115
- * A controller answers false while hidden or disposed, so the center overlay
116
- * only opens when no visible side TW tab can take the link.
117
- */
118
- export function openTiddlerInLiveTab(title: string): boolean {
119
- for (const controller of liveFrames) {
120
- if (controller.openTiddler(title)) return true
121
- }
122
- return false
99
+ /** Per-surface skin: the class names (and inline style) a surface wears. */
100
+ export interface TwFrameSkin {
101
+ /** Wrapper filling the surface body (owns the flex chain). */
102
+ view: string
103
+ /** `data-*` attributes for the view wrapper (panel tags it for its CSS). */
104
+ viewDataset?: Record<string, string>
105
+ /** Wrapper around the iframe. */
106
+ frameWrap: string
107
+ /** Inline style for the frame wrapper (the panel's has no stylesheet rule). */
108
+ frameWrapStyle?: string
109
+ /** The TW iframe. */
110
+ frame: string
111
+ /** Error / starting panel. */
112
+ error: string
123
113
  }
124
114
 
125
- /**
126
- * Refresh the shared surface label from `/status` (ui.tabLabel) so a surface
127
- * that mounts before the first status call already shows the right name.
128
- */
129
- export function warmTabLabel(): void {
130
- void fetchStatus().then((payload) => {
131
- if (payload?.ui !== undefined) setTabLabel(payload.ui.tabLabel ?? tabLabel)
132
- })
115
+ /** A TW iframe surface: DOM + lifecycle, shared by every embedding surface. */
116
+ export interface TwFrameSurface {
117
+ /**
118
+ * Build the surface DOM (view wrapper + iframe + error area) and return the
119
+ * view element; the CALLER decides where to append it. Idempotent, and safe
120
+ * to call AFTER `setVisible(true)`: a surface asked to show before its DOM
121
+ * existed (the panel builds lazily, once the center column appears) re-arms
122
+ * its `/status` load right here instead of stranding an empty surface.
123
+ */
124
+ build(): HTMLDivElement
125
+ /**
126
+ * Reflect visibility. The first `true` starts the lazy load and is the only
127
+ * thing that reveals the frame; `false` hides it and stops the bounded
128
+ * retry polling, so a later `true` starts a fresh budget. Only a frame that
129
+ * already carries a TW URL is ever revealed.
130
+ */
131
+ setVisible(visible: boolean): void
132
+ /** Open a tiddler by title; false when this surface cannot serve it. */
133
+ openTiddler(title: string): boolean
134
+ /** Tear down listeners/timers/theme sync and remove the view from the DOM. */
135
+ dispose(): void
133
136
  }
134
137
 
135
138
  /**
136
- * Create a TW frame controller inside `host`. The frame loads lazily on the
137
- * first `setVisible(true)`, polls `/status` (starting/restart/error states),
138
- * follows the DSH theme, reloads on the FAB's reload event, and navigates to
139
- * `#<title>` hashes once the frame is ready. `signal` (the tab's abort) tears
140
- * the whole controller down. Joins the live-frame registry on creation and
141
- * leaves it on dispose.
139
+ * Create the frame machinery for one surface. Every `/status`-driven decision
140
+ * (starting / error / running, iframe URL, theme config, shared chip label)
141
+ * and every tiddler-hash navigation is implemented here ONCE.
142
142
  */
143
- export function createTwFrameController(host: HTMLElement, signal: AbortSignal): TwFrameController {
144
- // Already aborted (the tab was closed before this body mounted): 'abort' will
145
- // never fire again, so registering here would strand the iframe in
146
- // liveFrames forever. Hand back a no-op controller and build nothing.
147
- if (signal.aborted) return disposedController()
148
-
143
+ export function createTwFrameSurface(skin: TwFrameSkin): TwFrameSurface {
149
144
  let visible = false
150
145
  let started = false
151
146
  let disposed = false
147
+ let view: HTMLDivElement | undefined
148
+ let frame: HTMLIFrameElement | undefined
149
+ let errorArea: HTMLDivElement | undefined
152
150
  let refreshTimer: number | undefined
153
151
  /** In-flight hash-readiness retry timers (cancelled by dispose, v0.19.1). */
154
152
  const hashWaitTimers = new Set<number>()
@@ -157,31 +155,16 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
157
155
  let pendingHash: string | null = null
158
156
  let themeSyncDispose: (() => void) | undefined
159
157
 
160
- const view = document.createElement('div')
161
- view.className = 'dsh-tw-rightbar-view'
162
-
163
- const frameArea = document.createElement('div')
164
- frameArea.className = 'dsh-tw-rightbar-frame-wrap'
165
- const frame = document.createElement('iframe')
166
- frame.className = 'dsh-tw-rightbar-frame'
167
- frame.title = 'TiddlyWiki'
168
- frame.hidden = true
169
- frameArea.append(frame)
170
-
171
- const errorArea = document.createElement('div')
172
- errorArea.className = 'dsh-tw-rightbar-error'
173
- errorArea.hidden = true
174
-
175
- view.append(frameArea, errorArea)
176
- host.append(view)
177
-
178
- frame.addEventListener('load', () => {
179
- frameLoaded = true
180
- applyPendingHash()
181
- })
182
- themeSyncDispose = attachThemeSync(frame)
158
+ /** Cancel the pending bounded retry (a new refresh supersedes it). */
159
+ const clearRetry = (): void => {
160
+ if (refreshTimer !== undefined) {
161
+ window.clearTimeout(refreshTimer)
162
+ refreshTimer = undefined
163
+ }
164
+ }
183
165
 
184
166
  const showError = (message: string): void => {
167
+ if (frame === undefined || errorArea === undefined) return
185
168
  frame.hidden = true
186
169
  errorArea.hidden = false
187
170
  errorArea.replaceChildren()
@@ -201,6 +184,7 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
201
184
  }
202
185
 
203
186
  const showStarting = (): void => {
187
+ if (frame === undefined || errorArea === undefined) return
204
188
  frame.hidden = true
205
189
  errorArea.hidden = false
206
190
  errorArea.replaceChildren()
@@ -210,8 +194,11 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
210
194
  }
211
195
 
212
196
  const showFrame = (url: string): void => {
197
+ if (frame === undefined || errorArea === undefined) return
213
198
  errorArea.hidden = true
214
- // Only reveal the frame while the surface is on screen.
199
+ // Only reveal the frame while the surface is on screen: doRefresh() can
200
+ // still be in flight after the surface was hidden, and an unconditional
201
+ // `hidden = false` would pop a closed surface back into view.
215
202
  frame.hidden = !visible
216
203
  // Set the src only when the url changed, so an editor never loses unsaved
217
204
  // state on a status refresh.
@@ -222,9 +209,22 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
222
209
  }
223
210
  }
224
211
 
225
- /** Apply a pending tiddler-hash once the frame is ready (see panel.ts). */
212
+ /**
213
+ * Apply a pending tiddler-hash navigation once the iframe document is ready.
214
+ * Preferred: same-origin `contentWindow.location.hash` — a hashchange INSIDE
215
+ * the frame (no reload), TW's story handler opens the tiddler. Fallback: a
216
+ * full `iframe.src` reload with the hash (TW auto-saves drafts, acceptable).
217
+ *
218
+ * TW registers its hashchange listener in a STARTUP module that runs after
219
+ * the iframe's `load` event, so setting `location.hash` right on load is a
220
+ * no-op (hash lost → TW stays on its home page). We therefore wait for TW to
221
+ * become ready inside the frame (the `$tw` global appears once boot settles)
222
+ * before setting the hash; if it never does (service down / slow boot) we
223
+ * fall back to a full reload with the hash, which TW processes at startup.
224
+ * A newer open request supersedes an in-flight wait.
225
+ */
226
226
  const applyPendingHash = (): void => {
227
- if (pendingHash === null || !frameLoaded) return
227
+ if (pendingHash === null || frame === undefined || !frameLoaded) return
228
228
  const hash = pendingHash
229
229
  const win = frame.contentWindow
230
230
  if (win === null) {
@@ -232,8 +232,8 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
232
232
  return
233
233
  }
234
234
  const tryOnce = (attempt: number): void => {
235
- if (disposed) return
236
- if (pendingHash !== hash) return
235
+ if (disposed) return // unmounted: do not keep waiting or touch the frame
236
+ if (pendingHash !== hash) return // superseded by a newer request
237
237
  const frameTw = win as { $tw?: unknown }
238
238
  if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
239
239
  if (attempt < 40) {
@@ -260,10 +260,11 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
260
260
  }
261
261
 
262
262
  const fallbackLoad = (hash: string): void => {
263
- if (disposed) return
263
+ if (disposed || frame === undefined) return // never drive a detached frame
264
264
  if (pendingHash === hash) pendingHash = null
265
265
  // Guard against the never-loaded frame: `frame.src` is '' before showFrame
266
- // ran, and `'' + '#title'` resolves against the DSH page URL (v0.22.3).
266
+ // ran, and `'' + '#title'` resolves against the DSH page URL, which loads
267
+ // the GUI into the iframe (v0.22.3).
267
268
  const base = loadableFrameUrl(frame.dataset)
268
269
  if (base === null) return
269
270
  const next = `${base.split('#')[0]}${hash}`
@@ -271,17 +272,16 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
271
272
  }
272
273
 
273
274
  const doRefresh = async (): Promise<void> => {
274
- if (refreshTimer !== undefined) {
275
- window.clearTimeout(refreshTimer)
276
- refreshTimer = undefined
277
- }
275
+ clearRetry()
278
276
  const payload = await fetchStatus()
279
- if (disposed) return
277
+ if (disposed) return // unmounted while fetching: stop, don't touch DOM
280
278
  if (payload === null) {
281
279
  showError('无法访问 /dsh-tiddlywiki/status')
282
280
  return
283
281
  }
284
282
  if (payload.ui !== undefined) {
283
+ // Shared surface label + theme config: both are module-level, so any
284
+ // surface that polls keeps them fresh for every other surface.
285
285
  setTabLabel(payload.ui.tabLabel ?? tabLabel)
286
286
  setThemeSyncConfig({
287
287
  enabled: payload.ui.followDshTheme !== false,
@@ -290,6 +290,9 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
290
290
  }
291
291
  if (payload.status === 'running') {
292
292
  refreshAttempts = 0
293
+ // Same-origin proxy URL: build from the page's own origin so it works no
294
+ // matter which host/domain the user reached DSH on. Fall back to the
295
+ // legacy loopback `url` for older servers that do not send twProxy.
293
296
  if (typeof payload.twProxy === 'string') {
294
297
  showFrame(new URL(payload.twProxy, window.location.origin).href)
295
298
  } else if (typeof payload.url === 'string') {
@@ -314,38 +317,78 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
314
317
  const onReloadRequest = (): void => {
315
318
  // Reload = re-assign the URL the frame is ALREADY showing (full reload, so
316
319
  // TW re-reads the wiki; `location.reload()` could be blocked mid-edit).
317
- // `hidden` alone is not enough (v0.22.3): setVisible(true) reveals the frame
318
- // before the first /status response sets its src, and `frame.src = frame.src`
319
- // with `src === ''` loads the DSH page into the iframe.
320
+ // The ONLY valid condition is `dataset.loaded` (v0.22.3): `frame.src` is ''
321
+ // before showFrame ran, and `frame.src = frame.src` with `src === ''` loads
322
+ // the DSH page into the iframe.
323
+ // Deliberately NOT gated on `frame.hidden`: the FAB entry is an EXPLICIT
324
+ // reload request, so it must reach every frame that really points at TW —
325
+ // including a closed panel that would otherwise reopen onto stale assets
326
+ // (v0.22.4; the panel used to reload in that state). An extra reload costs
327
+ // less than showing stale content.
328
+ if (frame === undefined) return
320
329
  const loaded = loadableFrameUrl(frame.dataset)
321
- if (loaded !== null && !frame.hidden) frame.src = loaded
330
+ if (loaded !== null) frame.src = loaded
322
331
  }
323
332
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
324
333
 
325
- const onAbort = (): void => controller.dispose()
326
- signal.addEventListener('abort', onAbort, { once: true })
334
+ const build = (): HTMLDivElement => {
335
+ if (view !== undefined) return view
336
+ const viewEl = document.createElement('div')
337
+ viewEl.className = skin.view
338
+ if (skin.viewDataset !== undefined) {
339
+ for (const [key, value] of Object.entries(skin.viewDataset)) viewEl.dataset[key] = value
340
+ }
341
+ const wrapEl = document.createElement('div')
342
+ wrapEl.className = skin.frameWrap
343
+ if (skin.frameWrapStyle !== undefined) wrapEl.style.cssText = skin.frameWrapStyle
344
+ const frameEl = document.createElement('iframe')
345
+ frameEl.className = skin.frame
346
+ frameEl.title = 'TiddlyWiki'
347
+ frameEl.hidden = true
348
+ wrapEl.append(frameEl)
349
+ const errorEl = document.createElement('div')
350
+ errorEl.className = skin.error
351
+ errorEl.hidden = true
352
+ viewEl.append(wrapEl, errorEl)
353
+ view = viewEl
354
+ frame = frameEl
355
+ errorArea = errorEl
356
+ // Track load so a pending tiddler-hash navigation can target a ready
357
+ // document (setting contentWindow.location.hash before load is a no-op).
358
+ frameEl.addEventListener('load', () => {
359
+ frameLoaded = true
360
+ applyPendingHash()
361
+ })
362
+ // Embedded TW follows the DSH light/dark theme (non-persisting palette
363
+ // swap inside the same-origin iframe; re-applied on load + theme change).
364
+ themeSyncDispose = attachThemeSync(frameEl)
365
+ // The surface may already have been asked to show before its DOM existed
366
+ // (the panel builds lazily): re-arm the load so it never stays empty.
367
+ if (visible) void doRefresh()
368
+ return viewEl
369
+ }
327
370
 
328
- const controller: TwFrameController = {
371
+ return {
372
+ build,
329
373
  setVisible(next: boolean): void {
374
+ if (disposed) return
330
375
  visible = next
331
- if (!started) {
332
- // Nothing loaded yet: stay hidden until the first show kicks the load.
333
- frame.hidden = true
334
- if (next) {
335
- started = true
336
- void doRefresh()
337
- }
338
- return
376
+ if (next && !started) started = true
377
+ if (frame !== undefined) {
378
+ // Reveal only a frame that already carries a TW URL; showFrame /
379
+ // showStarting decide what to display until the first /status lands.
380
+ frame.hidden = !next || loadableFrameUrl(frame.dataset) === null
381
+ }
382
+ if (next) {
383
+ // 每次重新显示都重探一次状态:启动轮询是有界的(30×1.5s),首次打开时
384
+ // 服务没起来就会把错误界面永久固定(切走再切回也不恢复)。doRefresh 自己
385
+ // 会清旧 timer,不会重复轮询。
386
+ void doRefresh()
387
+ } else {
388
+ // Hidden surfaces stop polling and get a fresh budget on the next show.
389
+ clearRetry()
390
+ refreshAttempts = 0
339
391
  }
340
- // Revealing before the first /status response is harmless as long as
341
- // every src-writing path checks `dataset.loaded` (onReloadRequest /
342
- // fallbackLoad), which also keeps the surface visible while a switch to
343
- // another wiki re-resolves its URL.
344
- frame.hidden = !next
345
- // 每次重新显示都重探一次状态:启动轮询是有界的(30×1.5s),首次打开时
346
- // 服务没起来就会把错误界面永久固定(切走再切回也不恢复)。doRefresh 自己
347
- // 会清旧 timer,不会重复轮询。
348
- if (next) void doRefresh()
349
392
  },
350
393
  openTiddler(title: string): boolean {
351
394
  if (disposed || !visible) return false
@@ -361,16 +404,105 @@ export function createTwFrameController(host: HTMLElement, signal: AbortSignal):
361
404
  dispose(): void {
362
405
  if (disposed) return
363
406
  disposed = true
364
- liveFrames.delete(controller)
365
407
  document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
366
- signal.removeEventListener('abort', onAbort)
367
- if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
408
+ clearRetry()
368
409
  for (const timer of hashWaitTimers) window.clearTimeout(timer)
369
410
  hashWaitTimers.clear()
370
411
  themeSyncDispose?.()
371
- view.remove()
412
+ view?.remove()
413
+ },
414
+ }
415
+ }
416
+
417
+ /** The plain-DOM TW frame controller owned by one surface body. */
418
+ export interface TwFrameController {
419
+ /** Reflect the surface's visibility; loads lazily on the first show. */
420
+ setVisible(visible: boolean): void
421
+ /** Open a tiddler by title; false when this controller cannot serve it. */
422
+ openTiddler(title: string): boolean
423
+ dispose(): void
424
+ }
425
+
426
+ /**
427
+ * Controller for an ALREADY-aborted signal: no DOM is ever built, so there is
428
+ * nothing to show and nothing to reap (the abort listener would never fire).
429
+ */
430
+ function disposedController(): TwFrameController {
431
+ return {
432
+ setVisible(): void {},
433
+ openTiddler(): boolean { return false },
434
+ dispose(): void {},
435
+ }
436
+ }
437
+
438
+ /** Live visible-capable TW frames; a controller joins on creation, leaves on dispose. */
439
+ const liveFrames = new Set<TwFrameController>()
440
+
441
+ /**
442
+ * Ask every live side TW surface to open a tiddler; true when one served it.
443
+ * A controller answers false while hidden or disposed, so the center overlay
444
+ * only opens when no visible side TW tab can take the link.
445
+ */
446
+ export function openTiddlerInLiveTab(title: string): boolean {
447
+ for (const controller of liveFrames) {
448
+ if (controller.openTiddler(title)) return true
449
+ }
450
+ return false
451
+ }
452
+
453
+ /**
454
+ * Refresh the shared surface label from `/status` (ui.tabLabel) so a surface
455
+ * that mounts before the first status call already shows the right name.
456
+ */
457
+ export function warmTabLabel(): void {
458
+ void fetchStatus().then((payload) => {
459
+ if (payload?.ui !== undefined) setTabLabel(payload.ui.tabLabel ?? tabLabel)
460
+ })
461
+ }
462
+
463
+ /** The rightbar tab's skin for the shared kernel (CSS owns the flex chain). */
464
+ const RIGHTBAR_SKIN: TwFrameSkin = {
465
+ view: 'dsh-tw-rightbar-view',
466
+ frameWrap: 'dsh-tw-rightbar-frame-wrap',
467
+ frame: 'dsh-tw-rightbar-frame',
468
+ error: 'dsh-tw-rightbar-error',
469
+ }
470
+
471
+ /**
472
+ * Create a TW frame controller inside `host`: the shared kernel (which loads
473
+ * lazily on the first `setVisible(true)`, polls `/status`, follows the DSH
474
+ * theme, reloads on the FAB's reload event and navigates `#<title>` hashes)
475
+ * plus the rightbar skin and the live-frame registry. `signal` (the tab's
476
+ * abort) tears the whole controller down.
477
+ */
478
+ export function createTwFrameController(host: HTMLElement, signal: AbortSignal): TwFrameController {
479
+ // Already aborted (the tab was closed before this body mounted): 'abort' will
480
+ // never fire again, so registering here would strand the iframe in
481
+ // liveFrames forever. Hand back a no-op controller and build nothing.
482
+ if (signal.aborted) return disposedController()
483
+
484
+ const surface = createTwFrameSurface(RIGHTBAR_SKIN)
485
+ host.append(surface.build())
486
+
487
+ let disposed = false
488
+ const onAbort = (): void => controller.dispose()
489
+ signal.addEventListener('abort', onAbort, { once: true })
490
+
491
+ const controller: TwFrameController = {
492
+ setVisible(visible: boolean): void {
493
+ surface.setVisible(visible)
494
+ },
495
+ openTiddler(title: string): boolean {
496
+ return surface.openTiddler(title)
497
+ },
498
+ dispose(): void {
499
+ if (disposed) return
500
+ disposed = true
501
+ liveFrames.delete(controller)
502
+ signal.removeEventListener('abort', onAbort)
503
+ surface.dispose()
372
504
  },
373
505
  }
374
506
  liveFrames.add(controller)
375
507
  return controller
376
- }
508
+ }