beyond-simulator-web 0.3.4 → 0.3.5

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.
@@ -5,6 +5,10 @@
5
5
  'use strict'
6
6
  const API = '/editor/api/play'
7
7
  const sessionId = decodeURIComponent(location.hash.slice(1))
8
+ const embedded = window.parent !== window && new URLSearchParams(location.search).get('embedded') === '1'
9
+ let closing = false
10
+ let stopped = false
11
+ let embeddedApi = null
8
12
  const $ = (id) => document.getElementById(id)
9
13
  const stage = $('stage')
10
14
  const cv = $('cv')
@@ -27,11 +31,16 @@
27
31
  if (!showLog) requestAnimationFrame(() => fitStage(true))
28
32
  }
29
33
 
30
- async function api(action, args = {}) {
34
+ function api(action, args = {}) {
35
+ if (closing && action !== 'stop') return Promise.reject(new Error('试玩正在关闭'))
36
+ return embeddedApi ? embeddedApi(action, args) : request(action, args)
37
+ }
38
+ async function request(action, args) {
31
39
  const response = await fetch(API, {
32
40
  method: 'POST',
33
41
  headers: { 'content-type': 'application/json' },
34
42
  body: JSON.stringify({ sessionId, action, args }),
43
+ signal: AbortSignal.timeout(15_000),
35
44
  })
36
45
  const envelope = await response.json().catch(() => null)
37
46
  if (!response.ok || !envelope || envelope.ok !== true) throw new Error(envelope?.error || '试玩请求失败')
@@ -188,6 +197,7 @@
188
197
  }
189
198
 
190
199
  function showError(error) {
200
+ if (closing) return
191
201
  $('loading').classList.add('hidden')
192
202
  $('fatal').classList.remove('hidden')
193
203
  $('fatal').textContent = error?.message || String(error)
@@ -201,6 +211,7 @@
201
211
  onSnapshot: renderChrome,
202
212
  onStatus: applyStatus,
203
213
  onError: showError,
214
+ isActive: () => !closing,
204
215
  })
205
216
  play.bindInput(stage, { keysOn: window })
206
217
 
@@ -263,7 +274,40 @@
263
274
  revealStage()
264
275
  } catch (error) { showError(error) }
265
276
  })
266
- $('close').addEventListener('click', async () => { if (play.running) await play.stop().catch(() => {}); window.close() })
277
+ function quiesce() {
278
+ closing = true
279
+ document.querySelector('.app').inert = true
280
+ play.reset()
281
+ }
282
+ function focusStage() {
283
+ stage.tabIndex = -1
284
+ stage.focus({ preventScroll: true })
285
+ }
286
+ stage.addEventListener('pointerdown', focusStage)
287
+ function requestEmbeddedClose() {
288
+ // Custom desktop schemes can serialize their origin as "null".
289
+ // The parent checks the exact iframe WindowProxy and session id.
290
+ parent.postMessage({ type: 'qxqy-play-close', sessionId }, location.origin === 'null' ? '*' : location.origin)
291
+ }
292
+ addEventListener('keydown', event => {
293
+ if (embedded && event.key === 'Escape') { event.preventDefault(); requestEmbeddedClose() }
294
+ })
295
+ $('close').addEventListener('click', async () => {
296
+ if (embedded) {
297
+ requestEmbeddedClose()
298
+ return
299
+ }
300
+ try {
301
+ quiesce()
302
+ await api('stop')
303
+ stopped = true
304
+ window.close()
305
+ } catch (error) {
306
+ closing = false
307
+ document.querySelector('.app').inert = false
308
+ showError(error)
309
+ }
310
+ })
267
311
  $('theme').addEventListener('click', () => { theme = theme === 'dark' ? 'light' : 'dark'; applyTheme() })
268
312
  let lastCase = null
269
313
  function parseAsserts() {
@@ -290,11 +334,36 @@
290
334
  for (const button of document.querySelectorAll('[data-view]')) button.addEventListener('click', () => setView(button.dataset.view))
291
335
  addEventListener('resize', () => fitStage(true))
292
336
  addEventListener('pagehide', () => {
337
+ closing = true
293
338
  play.destroy()
294
- fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, action: 'stop', args: {} }), keepalive: true }).catch(() => {})
339
+ // Embedded close/unmount is owned by the parent. A late keepalive stop
340
+ // from the old frame must never terminate the next play session.
341
+ if (!embedded && !stopped) fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, action: 'stop', args: {} }), keepalive: true }).catch(() => {})
295
342
  })
343
+ if (embedded) {
344
+ // Closing the whole application document has no awaitable teardown.
345
+ // This fallback is separate from normal modal close, which awaits stop.
346
+ const parentPagehide = () => {
347
+ quiesce()
348
+ fetch(API, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, action: 'stop', args: {} }), keepalive: true }).catch(() => {})
349
+ }
350
+ parent.addEventListener('pagehide', parentPagehide)
351
+ addEventListener('pagehide', () => parent.removeEventListener('pagehide', parentPagehide))
352
+ }
296
353
 
297
354
  if (!sessionId) showError(new Error('缺少会话 ID'))
298
- else play.start().then(() => setTimeout(revealStage, 0)).catch(showError)
355
+ else if (embedded) {
356
+ // Wait for the parent's load handler: closing a still-loading iframe
357
+ // must not race an automatic start from this document.
358
+ window.qxqyEmbeddedPlay = {
359
+ start: request => {
360
+ if (closing) return Promise.resolve()
361
+ embeddedApi = request
362
+ return play.start().then(() => { if (closing) play.reset(); else { revealStage(); focusStage() } })
363
+ },
364
+ quiesce,
365
+ focus: focusStage,
366
+ }
367
+ } else play.start().then(() => setTimeout(revealStage, 0)).catch(showError)
299
368
  })()
300
369