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.
- package/README.md +7 -4
- package/lib/client.js +27 -27
- package/package.json +2 -2
- package/src/client/panel.ts +40 -229
- package/src/client/tw-frame.ts +253 -121
package/src/client/tw-frame.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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).
|
|
82
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
*/
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
318
|
-
// before
|
|
319
|
-
//
|
|
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
|
|
330
|
+
if (loaded !== null) frame.src = loaded
|
|
322
331
|
}
|
|
323
332
|
document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
|
|
324
333
|
|
|
325
|
-
const
|
|
326
|
-
|
|
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
|
-
|
|
371
|
+
return {
|
|
372
|
+
build,
|
|
329
373
|
setVisible(next: boolean): void {
|
|
374
|
+
if (disposed) return
|
|
330
375
|
visible = next
|
|
331
|
-
if (!started)
|
|
332
|
-
|
|
333
|
-
frame
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|