db-gn 1.1.4 → 1.2.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.
- package/package.json +1 -1
- package/v2.html +1192 -35
package/package.json
CHANGED
package/v2.html
CHANGED
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
--bg1: #2e3244;
|
|
18
18
|
--bg2: #561a86;
|
|
19
19
|
--text: #ffffff;
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/* Opaque equivalents of what these used to be as translucent
|
|
21
|
+
layers -- rgba(0,0,0,.28) / rgba(176,0,230,.28) composited over
|
|
22
|
+
the tab bar's rgba(0,0,0,.22) veil over the page gradient
|
|
23
|
+
(#2e3244 at the top). Same look, but a dragged tab never shows
|
|
24
|
+
whatever it is passing over. */
|
|
25
|
+
--tab-bg: #1a1c26;
|
|
26
|
+
--tab-active: #4b1c67;
|
|
27
|
+
--tab-hover: #3c3f4b;
|
|
22
28
|
--sidebar-w: 150px;
|
|
23
29
|
--topbar-h: 58px;
|
|
24
30
|
}
|
|
@@ -116,11 +122,17 @@
|
|
|
116
122
|
transition: transform 0.15s ease, border-color 0.15s ease;
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
.tab-btn:hover
|
|
125
|
+
.tab-btn:hover {
|
|
126
|
+
transform: translateY(-1px);
|
|
127
|
+
border-color: rgba(255, 255, 255, 0.24);
|
|
128
|
+
background: var(--tab-hover);
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
#autoclick-btn:hover,
|
|
121
132
|
#refresh-favicon-btn:hover,
|
|
122
133
|
#bgrun-btn:hover,
|
|
123
|
-
#mute-btn:hover
|
|
134
|
+
#mute-btn:hover,
|
|
135
|
+
#export-btn:hover {
|
|
124
136
|
transform: translateY(-1px);
|
|
125
137
|
border-color: rgba(255, 255, 255, 0.24);
|
|
126
138
|
background: rgba(255, 255, 255, 0.11);
|
|
@@ -189,10 +201,58 @@
|
|
|
189
201
|
background: rgba(255, 80, 80, 0.35);
|
|
190
202
|
}
|
|
191
203
|
|
|
204
|
+
/* ---- Drag to reorder ----
|
|
205
|
+
Tabs slide out of the way while one is dragged. The dragged tab gets
|
|
206
|
+
an inline transform (which beats the hover rule) and no transition so
|
|
207
|
+
it tracks the pointer exactly; everyone else eases into their new
|
|
208
|
+
slot. The Home tab is never draggable and never displaced. */
|
|
209
|
+
.tab-btn.tab-sliding {
|
|
210
|
+
transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.25, 1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/* Worn for one frame while the drag is committed: the DOM reorder has
|
|
214
|
+
already put every tab in its final spot, so the leftover transforms
|
|
215
|
+
must be dropped instantly -- transitioning them plays the slide over
|
|
216
|
+
again backwards. Declared after .tab-sliding so it wins the tie. */
|
|
217
|
+
.tab-btn.tab-no-anim {
|
|
218
|
+
transition: none;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.tab-btn.dragging,
|
|
222
|
+
.tab-btn.dropping {
|
|
223
|
+
background: var(--tab-bg);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
.tab-btn.dragging.active,
|
|
227
|
+
.tab-btn.dropping.active {
|
|
228
|
+
background: var(--tab-active);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.tab-btn.dragging {
|
|
232
|
+
transition: none;
|
|
233
|
+
position: relative;
|
|
234
|
+
z-index: 5;
|
|
235
|
+
cursor: grabbing;
|
|
236
|
+
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.45);
|
|
237
|
+
border-color: rgba(255, 255, 255, 0.32);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.tab-btn.dropping {
|
|
241
|
+
transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.25, 1),
|
|
242
|
+
box-shadow 0.22s ease;
|
|
243
|
+
position: relative;
|
|
244
|
+
z-index: 5;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#tab-bar.reordering .tab-btn:hover {
|
|
248
|
+
transform: none;
|
|
249
|
+
}
|
|
250
|
+
|
|
192
251
|
#autoclick-btn,
|
|
193
252
|
#refresh-favicon-btn,
|
|
194
253
|
#bgrun-btn,
|
|
195
|
-
#mute-btn
|
|
254
|
+
#mute-btn,
|
|
255
|
+
#export-btn {
|
|
196
256
|
height: 42px;
|
|
197
257
|
width: 48px;
|
|
198
258
|
border-radius: 10px;
|
|
@@ -213,7 +273,8 @@
|
|
|
213
273
|
|
|
214
274
|
#refresh-favicon-btn,
|
|
215
275
|
#bgrun-btn,
|
|
216
|
-
#mute-btn
|
|
276
|
+
#mute-btn,
|
|
277
|
+
#export-btn {
|
|
217
278
|
margin-left: 6px;
|
|
218
279
|
}
|
|
219
280
|
|
|
@@ -227,7 +288,8 @@
|
|
|
227
288
|
#autoclick-btn svg,
|
|
228
289
|
#refresh-favicon-btn svg,
|
|
229
290
|
#bgrun-btn svg,
|
|
230
|
-
#mute-btn svg
|
|
291
|
+
#mute-btn svg,
|
|
292
|
+
#export-btn svg {
|
|
231
293
|
width: 22px;
|
|
232
294
|
height: 22px;
|
|
233
295
|
display: block;
|
|
@@ -257,6 +319,107 @@
|
|
|
257
319
|
cursor: not-allowed;
|
|
258
320
|
}
|
|
259
321
|
|
|
322
|
+
/* ---- Export / backup menu ---- */
|
|
323
|
+
#export-menu {
|
|
324
|
+
position: fixed;
|
|
325
|
+
z-index: 60;
|
|
326
|
+
display: none;
|
|
327
|
+
flex-direction: column;
|
|
328
|
+
gap: 4px;
|
|
329
|
+
min-width: 264px;
|
|
330
|
+
max-width: 340px;
|
|
331
|
+
max-height: 70vh;
|
|
332
|
+
overflow-y: auto;
|
|
333
|
+
padding: 6px;
|
|
334
|
+
background: rgba(30, 26, 46, 0.97);
|
|
335
|
+
border: 2px solid rgba(176, 0, 230, 0.45);
|
|
336
|
+
border-radius: 10px;
|
|
337
|
+
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.45);
|
|
338
|
+
backdrop-filter: blur(6px);
|
|
339
|
+
font-family: 'Comic Sans MS', sans-serif;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
#export-menu.open {
|
|
343
|
+
display: flex;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#export-menu .ex-head {
|
|
347
|
+
font-size: 12px;
|
|
348
|
+
letter-spacing: 0.06em;
|
|
349
|
+
text-transform: uppercase;
|
|
350
|
+
color: rgba(255, 255, 255, 0.55);
|
|
351
|
+
padding: 6px 10px 2px;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
#export-menu .ex-item {
|
|
355
|
+
appearance: none;
|
|
356
|
+
border: 0;
|
|
357
|
+
background: none;
|
|
358
|
+
font-family: inherit;
|
|
359
|
+
font-size: 15px;
|
|
360
|
+
text-align: left;
|
|
361
|
+
color: #fff;
|
|
362
|
+
padding: 9px 10px;
|
|
363
|
+
border-radius: 6px;
|
|
364
|
+
cursor: pointer;
|
|
365
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
366
|
+
display: flex;
|
|
367
|
+
align-items: center;
|
|
368
|
+
gap: 9px;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#export-menu .ex-item svg {
|
|
372
|
+
width: 17px;
|
|
373
|
+
height: 17px;
|
|
374
|
+
flex: 0 0 auto;
|
|
375
|
+
pointer-events: none;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#export-menu .ex-item:hover:not(:disabled),
|
|
379
|
+
#export-menu .ex-item:focus-visible {
|
|
380
|
+
background: rgba(176, 0, 230, 0.35);
|
|
381
|
+
outline: none;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#export-menu .ex-item:disabled {
|
|
385
|
+
opacity: 0.5;
|
|
386
|
+
cursor: progress;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#export-menu .ex-sep {
|
|
390
|
+
height: 1px;
|
|
391
|
+
margin: 4px 6px;
|
|
392
|
+
background: rgba(255, 255, 255, 0.15);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
#export-menu .ex-note:empty,
|
|
396
|
+
#export-menu .ex-status:empty {
|
|
397
|
+
display: none;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
#export-menu .ex-note {
|
|
401
|
+
font-size: 12px;
|
|
402
|
+
line-height: 1.45;
|
|
403
|
+
color: rgba(255, 255, 255, 0.6);
|
|
404
|
+
padding: 2px 10px 6px;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
#export-menu .ex-status {
|
|
408
|
+
font-size: 12.5px;
|
|
409
|
+
line-height: 1.45;
|
|
410
|
+
color: #ffd75e;
|
|
411
|
+
padding: 4px 10px 6px;
|
|
412
|
+
word-break: break-word;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#export-menu .ex-status.ok {
|
|
416
|
+
color: #7ff0a8;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#export-menu .ex-status.err {
|
|
420
|
+
color: #ff8b8b;
|
|
421
|
+
}
|
|
422
|
+
|
|
260
423
|
#autoclick-menu {
|
|
261
424
|
position: fixed;
|
|
262
425
|
z-index: 60;
|
|
@@ -1297,6 +1460,34 @@
|
|
|
1297
1460
|
tabBar.appendChild(btn);
|
|
1298
1461
|
}
|
|
1299
1462
|
|
|
1463
|
+
const ICON_EXPORT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1464
|
+
<path d="M12 3v12"/>
|
|
1465
|
+
<path d="M7 10.5l5 5 5-5"/>
|
|
1466
|
+
<path d="M4 20h16"/>
|
|
1467
|
+
</svg>`;
|
|
1468
|
+
|
|
1469
|
+
const ICON_EX_FILE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1470
|
+
<path d="M12 3v11"/>
|
|
1471
|
+
<path d="M8 10.5l4 4 4-4"/>
|
|
1472
|
+
<path d="M4 20h16"/>
|
|
1473
|
+
</svg>`;
|
|
1474
|
+
|
|
1475
|
+
const ICON_EX_COPY = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1476
|
+
<rect x="9" y="9" width="11" height="11" rx="2"/>
|
|
1477
|
+
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/>
|
|
1478
|
+
</svg>`;
|
|
1479
|
+
|
|
1480
|
+
const ICON_EX_IMPORT = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1481
|
+
<path d="M12 14V3"/>
|
|
1482
|
+
<path d="M8 6.5l4-4 4 4"/>
|
|
1483
|
+
<path d="M4 20h16"/>
|
|
1484
|
+
</svg>`;
|
|
1485
|
+
|
|
1486
|
+
const ICON_EX_PASTE = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1487
|
+
<rect x="5" y="4" width="14" height="17" rx="2"/>
|
|
1488
|
+
<path d="M9 4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1H9z" fill="currentColor"/>
|
|
1489
|
+
</svg>`;
|
|
1490
|
+
|
|
1300
1491
|
const ICON_BGRUN = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1301
1492
|
<circle cx="12" cy="12" r="8"/>
|
|
1302
1493
|
<path d="M12 7.5V12l3 1.8"/>
|
|
@@ -1346,15 +1537,18 @@
|
|
|
1346
1537
|
ensureAutoclickButton();
|
|
1347
1538
|
ensureRefreshFaviconButton();
|
|
1348
1539
|
ensureBackgroundRunButton();
|
|
1349
|
-
if (document.getElementById("mute-btn"))
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1540
|
+
if (!document.getElementById("mute-btn")) {
|
|
1541
|
+
const btn = document.createElement("button");
|
|
1542
|
+
btn.id = "mute-btn";
|
|
1543
|
+
btn.type = "button";
|
|
1544
|
+
btn.innerHTML = isMuted ? ICON_SOUND_OFF : ICON_SOUND_ON;
|
|
1545
|
+
btn.title = isMuted ? "Unmute All" : "Mute All";
|
|
1546
|
+
btn.setAttribute("aria-label", btn.title);
|
|
1547
|
+
btn.onclick = toggleMuteAll;
|
|
1548
|
+
tabBar.appendChild(btn);
|
|
1549
|
+
}
|
|
1550
|
+
// Appended last so it always sits to the right of the mute button.
|
|
1551
|
+
ensureExportButton();
|
|
1358
1552
|
}
|
|
1359
1553
|
|
|
1360
1554
|
function setMuteButtonState() {
|
|
@@ -1389,10 +1583,772 @@
|
|
|
1389
1583
|
saveMuteAndApply();
|
|
1390
1584
|
}
|
|
1391
1585
|
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1586
|
+
/* ---------------- Export / import of all site data ----------------
|
|
1587
|
+
Games run in srcdoc iframes with allow-same-origin, so everything they
|
|
1588
|
+
save lands in THIS page's origin: localStorage, sessionStorage,
|
|
1589
|
+
IndexedDB (where emulators and bigger games keep their saves) and
|
|
1590
|
+
cookies. A backup therefore has to walk all four, not just
|
|
1591
|
+
localStorage. Cache Storage is deliberately skipped: it only holds
|
|
1592
|
+
re-downloadable assets, never save data.
|
|
1593
|
+
|
|
1594
|
+
Import is a MERGE, never a wipe. Only the keys, stores and records
|
|
1595
|
+
present in the backup file are written, so a save that covers three
|
|
1596
|
+
games overwrites those three and leaves every other game alone. */
|
|
1597
|
+
|
|
1598
|
+
const BACKUP_FORMAT = "dancing-bunny-backup";
|
|
1599
|
+
const BACKUP_VERSION = 1;
|
|
1600
|
+
|
|
1601
|
+
/* Pure asset caches: Unity parks the downloaded game payload here (tens
|
|
1602
|
+
of MB per city for Subway Surfers alone) and re-downloads it whenever
|
|
1603
|
+
it's missing. Progress never lives here — Unity keeps that in
|
|
1604
|
+
/idbfs — so backing it up would balloon the file for nothing.
|
|
1605
|
+
Delete a name from this set if you ever do want it included. */
|
|
1606
|
+
const BACKUP_SKIP_DBS = new Set(["UnityCache"]);
|
|
1607
|
+
|
|
1608
|
+
/* JSON can't hold what IndexedDB hands back — ArrayBuffers, typed
|
|
1609
|
+
arrays, Blobs, Dates, Maps, Sets. Encode those as tagged objects and
|
|
1610
|
+
decode them on the way back in. */
|
|
1611
|
+
|
|
1612
|
+
function bytesToBase64(bytes) {
|
|
1613
|
+
let bin = "";
|
|
1614
|
+
const CHUNK = 0x8000;
|
|
1615
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
1616
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
1617
|
+
}
|
|
1618
|
+
return btoa(bin);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
function base64ToBytes(b64) {
|
|
1622
|
+
const bin = atob(b64);
|
|
1623
|
+
const out = new Uint8Array(bin.length);
|
|
1624
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1625
|
+
return out;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const TYPED_ARRAYS = {
|
|
1629
|
+
Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array,
|
|
1630
|
+
Int32Array, Uint32Array, Float32Array, Float64Array,
|
|
1631
|
+
...(typeof BigInt64Array !== "undefined" ? { BigInt64Array } : {}),
|
|
1632
|
+
...(typeof BigUint64Array !== "undefined" ? { BigUint64Array } : {}),
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
async function encodeValue(value, seen = new Set()) {
|
|
1636
|
+
if (value === undefined) return { __t: "undefined" };
|
|
1637
|
+
if (value === null) return null;
|
|
1638
|
+
|
|
1639
|
+
const type = typeof value;
|
|
1640
|
+
if (type === "boolean" || type === "string") return value;
|
|
1641
|
+
if (type === "number") {
|
|
1642
|
+
if (Number.isFinite(value)) return value;
|
|
1643
|
+
return { __t: "number", v: String(value) };
|
|
1644
|
+
}
|
|
1645
|
+
if (type === "bigint") return { __t: "bigint", v: value.toString() };
|
|
1646
|
+
if (type === "function" || type === "symbol") return { __t: "undefined" };
|
|
1647
|
+
|
|
1648
|
+
if (seen.has(value)) return { __t: "circular" };
|
|
1649
|
+
seen.add(value);
|
|
1650
|
+
try {
|
|
1651
|
+
if (value instanceof Date) return { __t: "Date", v: value.getTime() };
|
|
1652
|
+
if (value instanceof RegExp) return { __t: "RegExp", src: value.source, flags: value.flags };
|
|
1653
|
+
|
|
1654
|
+
if (value instanceof ArrayBuffer) {
|
|
1655
|
+
return { __t: "ArrayBuffer", v: bytesToBase64(new Uint8Array(value)) };
|
|
1656
|
+
}
|
|
1657
|
+
if (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer) {
|
|
1658
|
+
return { __t: "ArrayBuffer", v: bytesToBase64(new Uint8Array(value)) };
|
|
1659
|
+
}
|
|
1660
|
+
if (ArrayBuffer.isView(value)) {
|
|
1661
|
+
const name = value.constructor && value.constructor.name;
|
|
1662
|
+
if (name === "DataView") {
|
|
1663
|
+
return {
|
|
1664
|
+
__t: "DataView",
|
|
1665
|
+
v: bytesToBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)),
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
return {
|
|
1669
|
+
__t: "TypedArray",
|
|
1670
|
+
kind: name,
|
|
1671
|
+
v: bytesToBase64(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)),
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
if (typeof Blob !== "undefined" && value instanceof Blob) {
|
|
1676
|
+
const buf = await value.arrayBuffer();
|
|
1677
|
+
const out = {
|
|
1678
|
+
__t: "Blob",
|
|
1679
|
+
mime: value.type || "",
|
|
1680
|
+
v: bytesToBase64(new Uint8Array(buf)),
|
|
1681
|
+
};
|
|
1682
|
+
if (typeof File !== "undefined" && value instanceof File) {
|
|
1683
|
+
out.__t = "File";
|
|
1684
|
+
out.name = value.name;
|
|
1685
|
+
out.lastModified = value.lastModified;
|
|
1686
|
+
}
|
|
1687
|
+
return out;
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
if (value instanceof Map) {
|
|
1691
|
+
const entries = [];
|
|
1692
|
+
for (const [k, v] of value) {
|
|
1693
|
+
entries.push([await encodeValue(k, seen), await encodeValue(v, seen)]);
|
|
1694
|
+
}
|
|
1695
|
+
return { __t: "Map", v: entries };
|
|
1696
|
+
}
|
|
1697
|
+
if (value instanceof Set) {
|
|
1698
|
+
const items = [];
|
|
1699
|
+
for (const v of value) items.push(await encodeValue(v, seen));
|
|
1700
|
+
return { __t: "Set", v: items };
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (Array.isArray(value)) {
|
|
1704
|
+
const out = [];
|
|
1705
|
+
for (const v of value) out.push(await encodeValue(v, seen));
|
|
1706
|
+
return out;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Plain object. Objects that genuinely carry a "__t" key get
|
|
1710
|
+
// wrapped so decoding can't mistake them for a tag.
|
|
1711
|
+
const obj = {};
|
|
1712
|
+
for (const k of Object.keys(value)) {
|
|
1713
|
+
obj[k] = await encodeValue(value[k], seen);
|
|
1714
|
+
}
|
|
1715
|
+
if (Object.prototype.hasOwnProperty.call(value, "__t")) {
|
|
1716
|
+
return { __t: "object", v: obj };
|
|
1717
|
+
}
|
|
1718
|
+
return obj;
|
|
1719
|
+
} finally {
|
|
1720
|
+
seen.delete(value);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
function decodeValue(value) {
|
|
1725
|
+
if (value === null || typeof value !== "object") return value;
|
|
1726
|
+
if (Array.isArray(value)) return value.map(decodeValue);
|
|
1727
|
+
|
|
1728
|
+
const tag = value.__t;
|
|
1729
|
+
if (typeof tag !== "string") {
|
|
1730
|
+
const out = {};
|
|
1731
|
+
for (const k of Object.keys(value)) out[k] = decodeValue(value[k]);
|
|
1732
|
+
return out;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
switch (tag) {
|
|
1736
|
+
case "undefined": return undefined;
|
|
1737
|
+
case "circular": return undefined;
|
|
1738
|
+
case "number": return Number(value.v);
|
|
1739
|
+
case "bigint": return BigInt(value.v);
|
|
1740
|
+
case "Date": return new Date(value.v);
|
|
1741
|
+
case "RegExp": return new RegExp(value.src, value.flags);
|
|
1742
|
+
case "ArrayBuffer": {
|
|
1743
|
+
const bytes = base64ToBytes(value.v);
|
|
1744
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
1745
|
+
}
|
|
1746
|
+
case "DataView": {
|
|
1747
|
+
const bytes = base64ToBytes(value.v);
|
|
1748
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1749
|
+
}
|
|
1750
|
+
case "TypedArray": {
|
|
1751
|
+
const Ctor = TYPED_ARRAYS[value.kind] || Uint8Array;
|
|
1752
|
+
const bytes = base64ToBytes(value.v);
|
|
1753
|
+
if (Ctor === Uint8Array) return bytes;
|
|
1754
|
+
return new Ctor(bytes.buffer, bytes.byteOffset, bytes.byteLength / Ctor.BYTES_PER_ELEMENT);
|
|
1755
|
+
}
|
|
1756
|
+
case "Blob": return new Blob([base64ToBytes(value.v)], { type: value.mime || "" });
|
|
1757
|
+
case "File": {
|
|
1758
|
+
const blob = new Blob([base64ToBytes(value.v)], { type: value.mime || "" });
|
|
1759
|
+
try {
|
|
1760
|
+
return new File([blob], value.name || "file", {
|
|
1761
|
+
type: value.mime || "",
|
|
1762
|
+
lastModified: value.lastModified || Date.now(),
|
|
1763
|
+
});
|
|
1764
|
+
} catch (e) { return blob; }
|
|
1765
|
+
}
|
|
1766
|
+
case "Map": return new Map((value.v || []).map(([k, v]) => [decodeValue(k), decodeValue(v)]));
|
|
1767
|
+
case "Set": return new Set((value.v || []).map(decodeValue));
|
|
1768
|
+
case "object": {
|
|
1769
|
+
const out = {};
|
|
1770
|
+
for (const k of Object.keys(value.v || {})) out[k] = decodeValue(value.v[k]);
|
|
1771
|
+
return out;
|
|
1772
|
+
}
|
|
1773
|
+
default: return value;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
function idbRequest(req) {
|
|
1778
|
+
return new Promise((resolve, reject) => {
|
|
1779
|
+
req.onsuccess = () => resolve(req.result);
|
|
1780
|
+
req.onerror = () => reject(req.error);
|
|
1781
|
+
req.onblocked = () => reject(new Error("blocked"));
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
function idbTxDone(tx) {
|
|
1786
|
+
return new Promise((resolve, reject) => {
|
|
1787
|
+
tx.oncomplete = () => resolve();
|
|
1788
|
+
tx.onerror = () => reject(tx.error);
|
|
1789
|
+
tx.onabort = () => reject(tx.error || new Error("aborted"));
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
function openIdb(name, version) {
|
|
1794
|
+
return new Promise((resolve, reject) => {
|
|
1795
|
+
const req = version ? indexedDB.open(name, version) : indexedDB.open(name);
|
|
1796
|
+
req.onsuccess = () => resolve(req.result);
|
|
1797
|
+
req.onerror = () => reject(req.error);
|
|
1798
|
+
req.onblocked = () => reject(new Error(`"${name}" is open in another tab — close it and retry`));
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
async function snapshotIndexedDb(onProgress) {
|
|
1803
|
+
if (typeof indexedDB === "undefined" || !indexedDB.databases) return { databases: [], skipped: [], unsupported: true };
|
|
1804
|
+
|
|
1805
|
+
let list = [];
|
|
1806
|
+
try { list = await indexedDB.databases(); } catch (e) { return { databases: [], skipped: [], unsupported: true }; }
|
|
1807
|
+
|
|
1808
|
+
const databases = [];
|
|
1809
|
+
const skipped = [];
|
|
1810
|
+
for (const info of list) {
|
|
1811
|
+
if (!info || !info.name) continue;
|
|
1812
|
+
if (BACKUP_SKIP_DBS.has(info.name)) { skipped.push(info.name); continue; }
|
|
1813
|
+
if (onProgress) onProgress(info.name);
|
|
1814
|
+
let db;
|
|
1815
|
+
try { db = await openIdb(info.name); } catch (e) { continue; }
|
|
1816
|
+
|
|
1817
|
+
const dbOut = { name: db.name, version: db.version, stores: [] };
|
|
1818
|
+
const storeNames = Array.from(db.objectStoreNames);
|
|
1819
|
+
for (const storeName of storeNames) {
|
|
1820
|
+
try {
|
|
1821
|
+
const tx = db.transaction(storeName, "readonly");
|
|
1822
|
+
const store = tx.objectStore(storeName);
|
|
1823
|
+
const storeOut = {
|
|
1824
|
+
name: storeName,
|
|
1825
|
+
keyPath: store.keyPath === undefined ? null : store.keyPath,
|
|
1826
|
+
autoIncrement: !!store.autoIncrement,
|
|
1827
|
+
indexes: Array.from(store.indexNames).map(ix => {
|
|
1828
|
+
const idx = store.index(ix);
|
|
1829
|
+
return {
|
|
1830
|
+
name: idx.name,
|
|
1831
|
+
keyPath: idx.keyPath === undefined ? null : idx.keyPath,
|
|
1832
|
+
unique: !!idx.unique,
|
|
1833
|
+
multiEntry: !!idx.multiEntry,
|
|
1834
|
+
};
|
|
1835
|
+
}),
|
|
1836
|
+
records: [],
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
const rows = await new Promise((resolve, reject) => {
|
|
1840
|
+
const acc = [];
|
|
1841
|
+
const req = store.openCursor();
|
|
1842
|
+
req.onsuccess = () => {
|
|
1843
|
+
const cursor = req.result;
|
|
1844
|
+
if (!cursor) { resolve(acc); return; }
|
|
1845
|
+
acc.push({ key: cursor.key, value: cursor.value });
|
|
1846
|
+
cursor.continue();
|
|
1847
|
+
};
|
|
1848
|
+
req.onerror = () => reject(req.error);
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
for (const row of rows) {
|
|
1852
|
+
const rec = { value: await encodeValue(row.value) };
|
|
1853
|
+
// In-line keys travel inside the value; only out-of-line
|
|
1854
|
+
// keys have to be stored (and replayed) separately.
|
|
1855
|
+
if (store.keyPath === null || store.keyPath === undefined) {
|
|
1856
|
+
rec.key = await encodeValue(row.key);
|
|
1857
|
+
}
|
|
1858
|
+
storeOut.records.push(rec);
|
|
1859
|
+
}
|
|
1860
|
+
dbOut.stores.push(storeOut);
|
|
1861
|
+
} catch (e) { /* unreadable store — keep going */ }
|
|
1862
|
+
}
|
|
1863
|
+
db.close();
|
|
1864
|
+
databases.push(dbOut);
|
|
1865
|
+
}
|
|
1866
|
+
return { databases, skipped, unsupported: false };
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function readCookies() {
|
|
1870
|
+
const out = {};
|
|
1871
|
+
const raw = (document.cookie || "").split(";");
|
|
1872
|
+
for (const part of raw) {
|
|
1873
|
+
const trimmed = part.trim();
|
|
1874
|
+
if (!trimmed) continue;
|
|
1875
|
+
const eq = trimmed.indexOf("=");
|
|
1876
|
+
if (eq < 0) continue;
|
|
1877
|
+
out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
|
|
1878
|
+
}
|
|
1879
|
+
return out;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
function readWebStorage(store) {
|
|
1883
|
+
const out = {};
|
|
1884
|
+
if (!store) return out;
|
|
1885
|
+
try {
|
|
1886
|
+
for (let i = 0; i < store.length; i++) {
|
|
1887
|
+
const key = store.key(i);
|
|
1888
|
+
if (key === null) continue;
|
|
1889
|
+
out[key] = store.getItem(key);
|
|
1890
|
+
}
|
|
1891
|
+
} catch (e) { /* storage blocked */ }
|
|
1892
|
+
return out;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
async function buildBackup(onProgress) {
|
|
1896
|
+
if (onProgress) onProgress("Reading localStorage…");
|
|
1897
|
+
const local = readWebStorage(window.localStorage);
|
|
1898
|
+
if (onProgress) onProgress("Reading sessionStorage…");
|
|
1899
|
+
const session = readWebStorage(window.sessionStorage);
|
|
1900
|
+
const cookies = readCookies();
|
|
1901
|
+
|
|
1902
|
+
if (onProgress) onProgress("Reading IndexedDB…");
|
|
1903
|
+
const idb = await snapshotIndexedDb(name => {
|
|
1904
|
+
if (onProgress) onProgress(`Reading IndexedDB: ${name}…`);
|
|
1905
|
+
});
|
|
1906
|
+
|
|
1907
|
+
return {
|
|
1908
|
+
format: BACKUP_FORMAT,
|
|
1909
|
+
version: BACKUP_VERSION,
|
|
1910
|
+
exportedAt: new Date().toISOString(),
|
|
1911
|
+
origin: location.origin,
|
|
1912
|
+
href: location.href,
|
|
1913
|
+
userAgent: navigator.userAgent,
|
|
1914
|
+
notes: "Merge-restore: only the keys and records in this file are overwritten.",
|
|
1915
|
+
indexedDbSupported: !idb.unsupported,
|
|
1916
|
+
skippedDatabases: idb.skipped || [],
|
|
1917
|
+
data: {
|
|
1918
|
+
localStorage: local,
|
|
1919
|
+
sessionStorage: session,
|
|
1920
|
+
cookies,
|
|
1921
|
+
indexedDB: idb.databases,
|
|
1922
|
+
},
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function summarizeBackup(backup) {
|
|
1927
|
+
const d = backup.data || {};
|
|
1928
|
+
const localCount = Object.keys(d.localStorage || {}).length;
|
|
1929
|
+
const sessionCount = Object.keys(d.sessionStorage || {}).length;
|
|
1930
|
+
const cookieCount = Object.keys(d.cookies || {}).length;
|
|
1931
|
+
const dbs = d.indexedDB || [];
|
|
1932
|
+
let records = 0;
|
|
1933
|
+
let stores = 0;
|
|
1934
|
+
for (const db of dbs) {
|
|
1935
|
+
for (const store of db.stores || []) {
|
|
1936
|
+
stores++;
|
|
1937
|
+
records += (store.records || []).length;
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return { localCount, sessionCount, cookieCount, dbCount: dbs.length, stores, records };
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/* ---- Restore (merge) ---- */
|
|
1944
|
+
|
|
1945
|
+
function deleteIdb(name) {
|
|
1946
|
+
return new Promise((resolve, reject) => {
|
|
1947
|
+
const req = indexedDB.deleteDatabase(name);
|
|
1948
|
+
req.onsuccess = () => resolve();
|
|
1949
|
+
req.onerror = () => reject(req.error);
|
|
1950
|
+
req.onblocked = () => reject(new Error(`"${name}" is open in another tab — close it and retry`));
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/* Opens without naming a version purely to learn whether the database
|
|
1955
|
+
already exists: `upgradeneeded` only fires here when the browser had
|
|
1956
|
+
to create it. */
|
|
1957
|
+
function probeIdb(name) {
|
|
1958
|
+
return new Promise((resolve, reject) => {
|
|
1959
|
+
const req = indexedDB.open(name);
|
|
1960
|
+
let created = false;
|
|
1961
|
+
req.onupgradeneeded = () => { created = true; };
|
|
1962
|
+
req.onsuccess = () => resolve({ db: req.result, created });
|
|
1963
|
+
req.onerror = () => reject(req.error);
|
|
1964
|
+
req.onblocked = () => reject(new Error(`"${name}" is open in another tab — close it and retry`));
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
function createIdbStores(name, version, specs) {
|
|
1969
|
+
return new Promise((resolve, reject) => {
|
|
1970
|
+
const req = indexedDB.open(name, version);
|
|
1971
|
+
req.onupgradeneeded = () => {
|
|
1972
|
+
const upgraded = req.result;
|
|
1973
|
+
for (const spec of specs) {
|
|
1974
|
+
if (upgraded.objectStoreNames.contains(spec.name)) continue;
|
|
1975
|
+
const opts = {};
|
|
1976
|
+
if (spec.keyPath !== null && spec.keyPath !== undefined) opts.keyPath = spec.keyPath;
|
|
1977
|
+
if (spec.autoIncrement) opts.autoIncrement = true;
|
|
1978
|
+
const store = upgraded.createObjectStore(spec.name, opts);
|
|
1979
|
+
for (const idx of spec.indexes || []) {
|
|
1980
|
+
if (idx.keyPath === null || idx.keyPath === undefined) continue;
|
|
1981
|
+
try {
|
|
1982
|
+
store.createIndex(idx.name, idx.keyPath, {
|
|
1983
|
+
unique: !!idx.unique,
|
|
1984
|
+
multiEntry: !!idx.multiEntry,
|
|
1985
|
+
});
|
|
1986
|
+
} catch (e) { /* index already there */ }
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
};
|
|
1990
|
+
req.onsuccess = () => resolve(req.result);
|
|
1991
|
+
req.onerror = () => reject(req.error);
|
|
1992
|
+
req.onblocked = () => reject(new Error(`"${name}" is open in another tab — close it and retry`));
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
async function restoreIndexedDb(dbSpec) {
|
|
1997
|
+
const wanted = (dbSpec.stores || []);
|
|
1998
|
+
if (!wanted.length) return 0;
|
|
1999
|
+
|
|
2000
|
+
/* The database version has to come back EXACTLY as it was. Engines
|
|
2001
|
+
like Emscripten's IDBFS — which is how every Unity WebGL game here
|
|
2002
|
+
(Subway Surfers included) stores its progress — open their database
|
|
2003
|
+
at a hard-coded version: `indexedDB.open("/idbfs", 21)`. Recreating
|
|
2004
|
+
it at any higher number makes that call fail with VersionError, the
|
|
2005
|
+
game silently falls back to an empty filesystem, and it looks like
|
|
2006
|
+
a brand new save. */
|
|
2007
|
+
const targetVersion = Math.max(1, dbSpec.version || 1);
|
|
2008
|
+
|
|
2009
|
+
let probe = await probeIdb(dbSpec.name);
|
|
2010
|
+
let db = probe.db;
|
|
2011
|
+
|
|
2012
|
+
if (probe.created) {
|
|
2013
|
+
// It didn't exist. Probing created an empty v1 shell, so throw
|
|
2014
|
+
// that away and build it at the version the backup recorded.
|
|
2015
|
+
db.close();
|
|
2016
|
+
await deleteIdb(dbSpec.name);
|
|
2017
|
+
db = await createIdbStores(dbSpec.name, targetVersion, wanted);
|
|
2018
|
+
} else {
|
|
2019
|
+
const missing = wanted.filter(s => !db.objectStoreNames.contains(s.name));
|
|
2020
|
+
if (missing.length) {
|
|
2021
|
+
// Adding a store needs a version change. Prefer the recorded
|
|
2022
|
+
// version; only step past the live one when it's already
|
|
2023
|
+
// ahead, since there's no lower number available.
|
|
2024
|
+
const nextVersion = db.version < targetVersion ? targetVersion : db.version + 1;
|
|
2025
|
+
db.close();
|
|
2026
|
+
db = await createIdbStores(dbSpec.name, nextVersion, missing);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
let written = 0;
|
|
2031
|
+
for (const spec of wanted) {
|
|
2032
|
+
if (!db.objectStoreNames.contains(spec.name)) continue;
|
|
2033
|
+
const records = spec.records || [];
|
|
2034
|
+
if (!records.length) continue;
|
|
2035
|
+
const tx = db.transaction(spec.name, "readwrite");
|
|
2036
|
+
const store = tx.objectStore(spec.name);
|
|
2037
|
+
for (const rec of records) {
|
|
2038
|
+
const value = decodeValue(rec.value);
|
|
2039
|
+
try {
|
|
2040
|
+
// `put` overwrites only the matching key; untouched keys
|
|
2041
|
+
// in the same store survive.
|
|
2042
|
+
if (Object.prototype.hasOwnProperty.call(rec, "key")) {
|
|
2043
|
+
store.put(value, decodeValue(rec.key));
|
|
2044
|
+
} else {
|
|
2045
|
+
store.put(value);
|
|
2046
|
+
}
|
|
2047
|
+
written++;
|
|
2048
|
+
} catch (e) { /* one bad record shouldn't sink the store */ }
|
|
2049
|
+
}
|
|
2050
|
+
await idbTxDone(tx);
|
|
2051
|
+
}
|
|
2052
|
+
db.close();
|
|
2053
|
+
return written;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
async function restoreBackup(backup) {
|
|
2057
|
+
if (!backup || backup.format !== BACKUP_FORMAT) {
|
|
2058
|
+
throw new Error("Not a Dancing Bunny backup file.");
|
|
2059
|
+
}
|
|
2060
|
+
const d = backup.data || {};
|
|
2061
|
+
const result = { local: 0, session: 0, cookies: 0, records: 0, dbs: 0, errors: [] };
|
|
2062
|
+
|
|
2063
|
+
for (const [k, v] of Object.entries(d.localStorage || {})) {
|
|
2064
|
+
try { localStorage.setItem(k, v); result.local++; } catch (e) { result.errors.push(`localStorage ${k}`); }
|
|
2065
|
+
}
|
|
2066
|
+
for (const [k, v] of Object.entries(d.sessionStorage || {})) {
|
|
2067
|
+
try { sessionStorage.setItem(k, v); result.session++; } catch (e) { result.errors.push(`sessionStorage ${k}`); }
|
|
2068
|
+
}
|
|
2069
|
+
for (const [k, v] of Object.entries(d.cookies || {})) {
|
|
2070
|
+
try {
|
|
2071
|
+
document.cookie = `${k}=${v}; path=/; max-age=31536000; SameSite=Lax`;
|
|
2072
|
+
result.cookies++;
|
|
2073
|
+
} catch (e) { result.errors.push(`cookie ${k}`); }
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
for (const dbSpec of d.indexedDB || []) {
|
|
2077
|
+
if (!dbSpec || !dbSpec.name) continue;
|
|
2078
|
+
try {
|
|
2079
|
+
result.records += await restoreIndexedDb(dbSpec);
|
|
2080
|
+
result.dbs++;
|
|
2081
|
+
} catch (e) {
|
|
2082
|
+
result.errors.push(`IndexedDB "${dbSpec.name}": ${e && e.message ? e.message : e}`);
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return result;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/* ---- Menu ---- */
|
|
2089
|
+
|
|
2090
|
+
function backupFilename() {
|
|
2091
|
+
const d = new Date();
|
|
2092
|
+
const pad = n => String(n).padStart(2, "0");
|
|
2093
|
+
return `dancing-bunny-backup-${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}.json`;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function downloadText(text, filename) {
|
|
2097
|
+
const blob = new Blob([text], { type: "application/json" });
|
|
2098
|
+
const url = URL.createObjectURL(blob);
|
|
2099
|
+
const a = document.createElement("a");
|
|
2100
|
+
a.href = url;
|
|
2101
|
+
a.download = filename;
|
|
2102
|
+
document.body.appendChild(a);
|
|
2103
|
+
a.click();
|
|
2104
|
+
a.remove();
|
|
2105
|
+
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
async function copyText(text) {
|
|
2109
|
+
try {
|
|
2110
|
+
await navigator.clipboard.writeText(text);
|
|
2111
|
+
return true;
|
|
2112
|
+
} catch (e) { /* fall through to the legacy path */ }
|
|
2113
|
+
try {
|
|
2114
|
+
const ta = document.createElement("textarea");
|
|
2115
|
+
ta.value = text;
|
|
2116
|
+
ta.style.position = "fixed";
|
|
2117
|
+
ta.style.opacity = "0";
|
|
2118
|
+
document.body.appendChild(ta);
|
|
2119
|
+
ta.select();
|
|
2120
|
+
const ok = document.execCommand("copy");
|
|
2121
|
+
ta.remove();
|
|
2122
|
+
return ok;
|
|
2123
|
+
} catch (e) { return false; }
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
function setExportStatus(text, kind = "") {
|
|
2127
|
+
const el = document.getElementById("export-status");
|
|
2128
|
+
if (!el) return;
|
|
2129
|
+
el.textContent = text;
|
|
2130
|
+
el.className = `ex-status${kind ? " " + kind : ""}`;
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
function setExportBusy(busy) {
|
|
2134
|
+
const menu = document.getElementById("export-menu");
|
|
2135
|
+
if (!menu) return;
|
|
2136
|
+
menu.querySelectorAll(".ex-item").forEach(btn => { btn.disabled = busy; });
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
function ensureExportMenu() {
|
|
2140
|
+
let menu = document.getElementById("export-menu");
|
|
2141
|
+
if (menu) return menu;
|
|
2142
|
+
|
|
2143
|
+
menu = document.createElement("div");
|
|
2144
|
+
menu.id = "export-menu";
|
|
2145
|
+
menu.setAttribute("role", "menu");
|
|
2146
|
+
|
|
2147
|
+
const mkItem = (icon, label, onClick) => {
|
|
2148
|
+
const btn = document.createElement("button");
|
|
2149
|
+
btn.type = "button";
|
|
2150
|
+
btn.className = "ex-item";
|
|
2151
|
+
btn.setAttribute("role", "menuitem");
|
|
2152
|
+
btn.innerHTML = `${icon}<span></span>`;
|
|
2153
|
+
btn.querySelector("span").textContent = label;
|
|
2154
|
+
btn.onclick = onClick;
|
|
2155
|
+
return btn;
|
|
2156
|
+
};
|
|
2157
|
+
|
|
2158
|
+
const head = document.createElement("div");
|
|
2159
|
+
head.className = "ex-head";
|
|
2160
|
+
head.textContent = "Back up";
|
|
2161
|
+
menu.appendChild(head);
|
|
2162
|
+
|
|
2163
|
+
menu.appendChild(mkItem(ICON_EX_FILE, "Download data file", async () => {
|
|
2164
|
+
setExportBusy(true);
|
|
2165
|
+
setExportStatus("Collecting data…");
|
|
2166
|
+
try {
|
|
2167
|
+
const backup = await buildBackup(msg => setExportStatus(msg));
|
|
2168
|
+
const text = JSON.stringify(backup);
|
|
2169
|
+
downloadText(text, backupFilename());
|
|
2170
|
+
const s = summarizeBackup(backup);
|
|
2171
|
+
setExportStatus(
|
|
2172
|
+
`Saved ${s.localCount} local keys, ${s.records} IndexedDB records across ${s.dbCount} databases.`,
|
|
2173
|
+
"ok"
|
|
2174
|
+
);
|
|
2175
|
+
} catch (e) {
|
|
2176
|
+
setExportStatus(`Export failed: ${e && e.message ? e.message : e}`, "err");
|
|
2177
|
+
} finally { setExportBusy(false); }
|
|
2178
|
+
}));
|
|
2179
|
+
|
|
2180
|
+
menu.appendChild(mkItem(ICON_EX_COPY, "Copy data to clipboard", async () => {
|
|
2181
|
+
setExportBusy(true);
|
|
2182
|
+
setExportStatus("Collecting data…");
|
|
2183
|
+
try {
|
|
2184
|
+
const backup = await buildBackup(msg => setExportStatus(msg));
|
|
2185
|
+
const text = JSON.stringify(backup);
|
|
2186
|
+
const ok = await copyText(text);
|
|
2187
|
+
const s = summarizeBackup(backup);
|
|
2188
|
+
if (ok) {
|
|
2189
|
+
setExportStatus(
|
|
2190
|
+
`Copied ${(text.length / 1024).toFixed(0)} KB — ${s.localCount} local keys, ${s.records} IndexedDB records.`,
|
|
2191
|
+
"ok"
|
|
2192
|
+
);
|
|
2193
|
+
} else {
|
|
2194
|
+
setExportStatus("Clipboard blocked by the browser. Use the download instead.", "err");
|
|
2195
|
+
}
|
|
2196
|
+
} catch (e) {
|
|
2197
|
+
setExportStatus(`Copy failed: ${e && e.message ? e.message : e}`, "err");
|
|
2198
|
+
} finally { setExportBusy(false); }
|
|
2199
|
+
}));
|
|
2200
|
+
|
|
2201
|
+
const sep = document.createElement("div");
|
|
2202
|
+
sep.className = "ex-sep";
|
|
2203
|
+
menu.appendChild(sep);
|
|
2204
|
+
|
|
2205
|
+
const head2 = document.createElement("div");
|
|
2206
|
+
head2.className = "ex-head";
|
|
2207
|
+
head2.textContent = "Restore";
|
|
2208
|
+
menu.appendChild(head2);
|
|
2209
|
+
|
|
2210
|
+
const file = document.createElement("input");
|
|
2211
|
+
file.type = "file";
|
|
2212
|
+
file.accept = "application/json,.json";
|
|
2213
|
+
file.style.display = "none";
|
|
2214
|
+
file.onchange = async () => {
|
|
2215
|
+
const chosen = file.files && file.files[0];
|
|
2216
|
+
file.value = "";
|
|
2217
|
+
if (!chosen) return;
|
|
2218
|
+
await applyBackupText(await chosen.text());
|
|
2219
|
+
};
|
|
2220
|
+
menu.appendChild(file);
|
|
2221
|
+
|
|
2222
|
+
menu.appendChild(mkItem(ICON_EX_IMPORT, "Restore from file…", () => file.click()));
|
|
2223
|
+
|
|
2224
|
+
menu.appendChild(mkItem(ICON_EX_PASTE, "Restore from clipboard", async () => {
|
|
2225
|
+
let text = "";
|
|
2226
|
+
try {
|
|
2227
|
+
text = await navigator.clipboard.readText();
|
|
2228
|
+
} catch (e) {
|
|
2229
|
+
setExportStatus("Clipboard read blocked. Use “Restore from file” instead.", "err");
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
await applyBackupText(text);
|
|
2233
|
+
}));
|
|
2234
|
+
|
|
2235
|
+
const note = document.createElement("div");
|
|
2236
|
+
note.className = "ex-note";
|
|
2237
|
+
note.textContent = "";
|
|
2238
|
+
menu.appendChild(note);
|
|
2239
|
+
|
|
2240
|
+
const status = document.createElement("div");
|
|
2241
|
+
status.id = "export-status";
|
|
2242
|
+
status.className = "ex-status";
|
|
2243
|
+
menu.appendChild(status);
|
|
2244
|
+
|
|
2245
|
+
document.body.appendChild(menu);
|
|
2246
|
+
|
|
2247
|
+
document.addEventListener("mousedown", (e) => {
|
|
2248
|
+
if (!isExportMenuOpen()) return;
|
|
2249
|
+
const target = e.target instanceof Element ? e.target : null;
|
|
2250
|
+
if (target && menu.contains(target)) return;
|
|
2251
|
+
if (target && target.closest("#export-btn")) return;
|
|
2252
|
+
closeExportMenu();
|
|
2253
|
+
});
|
|
2254
|
+
window.addEventListener("resize", closeExportMenu);
|
|
2255
|
+
|
|
2256
|
+
return menu;
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
async function applyBackupText(text) {
|
|
2260
|
+
setExportBusy(true);
|
|
2261
|
+
setExportStatus("Reading backup…");
|
|
2262
|
+
let backup;
|
|
2263
|
+
try {
|
|
2264
|
+
backup = JSON.parse(text);
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
setExportStatus("That isn't valid backup JSON.", "err");
|
|
2267
|
+
setExportBusy(false);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (!backup || backup.format !== BACKUP_FORMAT) {
|
|
2271
|
+
setExportStatus("Not a Dancing Bunny backup file.", "err");
|
|
2272
|
+
setExportBusy(false);
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
const s = summarizeBackup(backup);
|
|
2277
|
+
const ok = confirm(
|
|
2278
|
+
`Restore this backup?\n\n` +
|
|
2279
|
+
`Taken: ${backup.exportedAt || "unknown"}\n` +
|
|
2280
|
+
`${s.localCount} localStorage keys, ${s.sessionCount} session keys, ${s.cookieCount} cookies\n` +
|
|
2281
|
+
`${s.records} IndexedDB records in ${s.dbCount} databases\n\n` +
|
|
2282
|
+
`This merges into your current data: only the games included above ` +
|
|
2283
|
+
`are overwritten. Games not in this backup keep their saves.`
|
|
2284
|
+
);
|
|
2285
|
+
if (!ok) {
|
|
2286
|
+
setExportStatus("Restore cancelled.");
|
|
2287
|
+
setExportBusy(false);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
try {
|
|
2292
|
+
setExportStatus("Restoring…");
|
|
2293
|
+
const res = await restoreBackup(backup);
|
|
2294
|
+
let msg = `Restored ${res.local} local keys and ${res.records} IndexedDB records across ${res.dbs} databases.`;
|
|
2295
|
+
if (res.errors.length) msg += ` ${res.errors.length} item(s) failed: ${res.errors[0]}`;
|
|
2296
|
+
setExportStatus(msg + " Reloading…", res.errors.length ? "err" : "ok");
|
|
2297
|
+
setTimeout(() => location.reload(), res.errors.length ? 2500 : 1200);
|
|
2298
|
+
} catch (e) {
|
|
2299
|
+
setExportStatus(`Restore failed: ${e && e.message ? e.message : e}`, "err");
|
|
2300
|
+
} finally { setExportBusy(false); }
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
function isExportMenuOpen() {
|
|
2304
|
+
return !!document.getElementById("export-menu")?.classList.contains("open");
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
function closeExportMenu() {
|
|
2308
|
+
document.getElementById("export-menu")?.classList.remove("open");
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
function openExportMenu() {
|
|
2312
|
+
const btn = document.getElementById("export-btn");
|
|
2313
|
+
const menu = ensureExportMenu();
|
|
2314
|
+
setExportStatus("");
|
|
2315
|
+
setExportBusy(false);
|
|
2316
|
+
menu.classList.add("open");
|
|
2317
|
+
|
|
2318
|
+
if (!btn) return;
|
|
2319
|
+
const anchor = btn.getBoundingClientRect();
|
|
2320
|
+
const rect = menu.getBoundingClientRect();
|
|
2321
|
+
const margin = 8;
|
|
2322
|
+
let left = anchor.right - rect.width;
|
|
2323
|
+
let top = anchor.bottom + 6;
|
|
2324
|
+
left = Math.max(margin, Math.min(left, window.innerWidth - rect.width - margin));
|
|
2325
|
+
top = Math.max(margin, Math.min(top, window.innerHeight - rect.height - margin));
|
|
2326
|
+
menu.style.left = `${left}px`;
|
|
2327
|
+
menu.style.top = `${top}px`;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function toggleExportMenu() {
|
|
2331
|
+
if (isExportMenuOpen()) closeExportMenu();
|
|
2332
|
+
else openExportMenu();
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
function ensureExportButton() {
|
|
2336
|
+
if (document.getElementById("export-btn")) return;
|
|
2337
|
+
const btn = document.createElement("button");
|
|
2338
|
+
btn.id = "export-btn";
|
|
2339
|
+
btn.type = "button";
|
|
2340
|
+
btn.innerHTML = ICON_EXPORT;
|
|
2341
|
+
btn.title = "Export / restore all data";
|
|
2342
|
+
btn.setAttribute("aria-label", btn.title);
|
|
2343
|
+
btn.onclick = toggleExportMenu;
|
|
2344
|
+
tabBar.appendChild(btn);
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// New tabs land immediately to the right of Home, so the newest game is
|
|
2348
|
+
// always the first thing after it; drag-reordering moves them from there.
|
|
2349
|
+
function insertTabButton(button) {
|
|
2350
|
+
const home = document.getElementById("tab-home");
|
|
2351
|
+
tabBar.insertBefore(button, home ? home.nextSibling : tabBar.firstChild);
|
|
1396
2352
|
}
|
|
1397
2353
|
|
|
1398
2354
|
/* ---------------- Auto clicker ----------------
|
|
@@ -2042,15 +2998,9 @@
|
|
|
2042
2998
|
|
|
2043
2999
|
if (idx !== -1) tabOrder.splice(idx, 1);
|
|
2044
3000
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
nextId = tabOrder[idx - 1];
|
|
2049
|
-
} else if (tabOrder.length > 0) {
|
|
2050
|
-
nextId = tabOrder[0];
|
|
2051
|
-
}
|
|
2052
|
-
activateTab(nextId);
|
|
2053
|
-
}
|
|
3001
|
+
// Closing the tab you're on always lands you back on Home rather
|
|
3002
|
+
// than on whichever tab happened to be next to it.
|
|
3003
|
+
if (wasActive) activateTab("home");
|
|
2054
3004
|
}
|
|
2055
3005
|
|
|
2056
3006
|
function createTabButton(id, title, closable) {
|
|
@@ -2075,6 +3025,213 @@
|
|
|
2075
3025
|
return btn;
|
|
2076
3026
|
}
|
|
2077
3027
|
|
|
3028
|
+
/* ---------------- Drag to reorder tabs ----------------
|
|
3029
|
+
Pointer-driven so the tab can be locked to the X axis (HTML5 DnD
|
|
3030
|
+
gives no such control) and so the other tabs can slide with a
|
|
3031
|
+
transition instead of jumping. Home stays pinned at index 0: it is
|
|
3032
|
+
neither draggable nor a slot anything else can land on. */
|
|
3033
|
+
const TAB_DRAG_THRESHOLD = 4;
|
|
3034
|
+
let tabDrag = null;
|
|
3035
|
+
let suppressTabClick = false;
|
|
3036
|
+
|
|
3037
|
+
function draggableTabButtons() {
|
|
3038
|
+
return [...tabBar.querySelectorAll(".tab-btn")].filter(b => b.id !== "tab-home");
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
function tabBarGap() {
|
|
3042
|
+
const gap = parseFloat(getComputedStyle(tabBar).columnGap);
|
|
3043
|
+
return Number.isFinite(gap) ? gap : 0;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
// tabOrder drives which tab gets focus after a close, so it has to
|
|
3047
|
+
// follow the visual order.
|
|
3048
|
+
function syncTabOrderFromDom() {
|
|
3049
|
+
const ids = draggableTabButtons()
|
|
3050
|
+
.map(b => b.id.slice(4))
|
|
3051
|
+
.filter(id => tabs.has(id));
|
|
3052
|
+
tabOrder.length = 0;
|
|
3053
|
+
tabOrder.push(...ids);
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
function beginTabDrag(e, btn) {
|
|
3057
|
+
const items = draggableTabButtons();
|
|
3058
|
+
const index = items.indexOf(btn);
|
|
3059
|
+
if (index === -1) return;
|
|
3060
|
+
|
|
3061
|
+
tabDrag = {
|
|
3062
|
+
btn,
|
|
3063
|
+
pointerId: e.pointerId,
|
|
3064
|
+
startX: e.clientX,
|
|
3065
|
+
startScroll: tabBar.scrollLeft,
|
|
3066
|
+
fromIndex: index,
|
|
3067
|
+
toIndex: index,
|
|
3068
|
+
moved: false,
|
|
3069
|
+
items: items.map(el => ({
|
|
3070
|
+
el,
|
|
3071
|
+
left: el.offsetLeft,
|
|
3072
|
+
width: el.offsetWidth
|
|
3073
|
+
})),
|
|
3074
|
+
step: btn.offsetWidth + tabBarGap()
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
function updateTabDrag(e) {
|
|
3079
|
+
const d = tabDrag;
|
|
3080
|
+
const dx = (e.clientX - d.startX) + (tabBar.scrollLeft - d.startScroll);
|
|
3081
|
+
|
|
3082
|
+
if (!d.moved) {
|
|
3083
|
+
if (Math.abs(dx) < TAB_DRAG_THRESHOLD) return;
|
|
3084
|
+
d.moved = true;
|
|
3085
|
+
tabBar.classList.add("reordering");
|
|
3086
|
+
d.btn.classList.add("dragging");
|
|
3087
|
+
d.items.forEach(item => {
|
|
3088
|
+
if (item.el !== d.btn) item.el.classList.add("tab-sliding");
|
|
3089
|
+
});
|
|
3090
|
+
// Force a style flush so the first shift animates from the
|
|
3091
|
+
// resting position instead of being batched with the class add.
|
|
3092
|
+
void tabBar.offsetWidth;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// Locked to X, and kept inside the run of draggable tabs so it can
|
|
3096
|
+
// never be dragged over Home or past the trailing controls.
|
|
3097
|
+
const first = d.items[0];
|
|
3098
|
+
const last = d.items[d.items.length - 1];
|
|
3099
|
+
const me = d.items[d.fromIndex];
|
|
3100
|
+
const min = first.left - me.left;
|
|
3101
|
+
const max = (last.left + last.width - me.width) - me.left;
|
|
3102
|
+
const clamped = Math.min(Math.max(dx, min), max);
|
|
3103
|
+
d.btn.style.transform = `translateX(${clamped}px) scale(1.02)`;
|
|
3104
|
+
|
|
3105
|
+
/* Crossing is measured edge-to-center, not center-to-center: a tab
|
|
3106
|
+
dragged right takes a slot once its right edge passes that
|
|
3107
|
+
neighbour's midpoint (mirrored going left). Comparing centers
|
|
3108
|
+
breaks whenever the dragged tab is wider than its neighbour --
|
|
3109
|
+
the clamp above stops its center from ever reaching the
|
|
3110
|
+
neighbour's, so the swap could only land on release. */
|
|
3111
|
+
let target = d.fromIndex;
|
|
3112
|
+
d.items.forEach((item, i) => {
|
|
3113
|
+
if (i === d.fromIndex) return;
|
|
3114
|
+
const itemCenter = item.left + item.width / 2;
|
|
3115
|
+
if (i > d.fromIndex && clamped > itemCenter - me.left - me.width) {
|
|
3116
|
+
target = Math.max(target, i);
|
|
3117
|
+
}
|
|
3118
|
+
if (i < d.fromIndex && clamped < itemCenter - me.left) {
|
|
3119
|
+
target = Math.min(target, i);
|
|
3120
|
+
}
|
|
3121
|
+
});
|
|
3122
|
+
|
|
3123
|
+
d.toIndex = target;
|
|
3124
|
+
// Applied every move, not just on a crossing: setting the same
|
|
3125
|
+
// transform is a no-op, so in-flight slides are never restarted,
|
|
3126
|
+
// but a slot can never be left un-shifted mid-drag either.
|
|
3127
|
+
applyTabDragShifts();
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
function applyTabDragShifts() {
|
|
3131
|
+
const d = tabDrag;
|
|
3132
|
+
d.items.forEach((item, i) => {
|
|
3133
|
+
if (i === d.fromIndex) return;
|
|
3134
|
+
let shift = 0;
|
|
3135
|
+
if (i > d.fromIndex && i <= d.toIndex) shift = -d.step;
|
|
3136
|
+
else if (i < d.fromIndex && i >= d.toIndex) shift = d.step;
|
|
3137
|
+
item.el.style.transform = shift ? `translateX(${shift}px)` : "";
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
function finishTabDrag(cancelled) {
|
|
3142
|
+
const d = tabDrag;
|
|
3143
|
+
tabDrag = null;
|
|
3144
|
+
if (!d) return;
|
|
3145
|
+
|
|
3146
|
+
try { d.btn.releasePointerCapture(d.pointerId); } catch (e) { }
|
|
3147
|
+
|
|
3148
|
+
if (!d.moved) {
|
|
3149
|
+
clearTabDragStyles(d);
|
|
3150
|
+
return;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
suppressTabClick = true;
|
|
3154
|
+
const toIndex = cancelled ? d.fromIndex : d.toIndex;
|
|
3155
|
+
const me = d.items[d.fromIndex];
|
|
3156
|
+
const dest = d.items[toIndex];
|
|
3157
|
+
const finalX = toIndex > d.fromIndex
|
|
3158
|
+
? (dest.left + dest.width - me.width) - me.left
|
|
3159
|
+
: dest.left - me.left;
|
|
3160
|
+
|
|
3161
|
+
// Let the tab glide into its slot, then commit the reorder with the
|
|
3162
|
+
// transforms cleared so nothing visibly jumps.
|
|
3163
|
+
d.btn.classList.remove("dragging");
|
|
3164
|
+
d.btn.classList.add("dropping");
|
|
3165
|
+
d.btn.style.transform = `translateX(${finalX}px)`;
|
|
3166
|
+
|
|
3167
|
+
const commit = () => {
|
|
3168
|
+
d.btn.removeEventListener("transitionend", onEnd);
|
|
3169
|
+
clearTimeout(timer);
|
|
3170
|
+
if (!cancelled && toIndex !== d.fromIndex) {
|
|
3171
|
+
const ref = toIndex > d.fromIndex
|
|
3172
|
+
? d.items[toIndex].el.nextElementSibling
|
|
3173
|
+
: d.items[toIndex].el;
|
|
3174
|
+
tabBar.insertBefore(d.btn, ref);
|
|
3175
|
+
syncTabOrderFromDom();
|
|
3176
|
+
}
|
|
3177
|
+
clearTabDragStyles(d);
|
|
3178
|
+
};
|
|
3179
|
+
const onEnd = (ev) => { if (ev.propertyName === "transform") commit(); };
|
|
3180
|
+
d.btn.addEventListener("transitionend", onEnd);
|
|
3181
|
+
const timer = setTimeout(commit, 300);
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
function clearTabDragStyles(d) {
|
|
3185
|
+
tabBar.classList.remove("reordering");
|
|
3186
|
+
d.items.forEach(item => item.el.classList.add("tab-no-anim"));
|
|
3187
|
+
d.btn.classList.remove("dragging", "dropping");
|
|
3188
|
+
d.items.forEach(item => {
|
|
3189
|
+
item.el.classList.remove("tab-sliding");
|
|
3190
|
+
item.el.style.transform = "";
|
|
3191
|
+
});
|
|
3192
|
+
// Flush the cleared transforms before transitions are allowed back.
|
|
3193
|
+
void tabBar.offsetWidth;
|
|
3194
|
+
d.items.forEach(item => item.el.classList.remove("tab-no-anim"));
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
tabBar.addEventListener("pointerdown", (e) => {
|
|
3198
|
+
if (e.button !== 0 || tabDrag) return;
|
|
3199
|
+
suppressTabClick = false;
|
|
3200
|
+
const btn = e.target.closest(".tab-btn");
|
|
3201
|
+
if (!btn || btn.id === "tab-home") return;
|
|
3202
|
+
if (e.target.closest(".tab-close")) return;
|
|
3203
|
+
beginTabDrag(e, btn);
|
|
3204
|
+
if (tabDrag) {
|
|
3205
|
+
try { btn.setPointerCapture(e.pointerId); } catch (err) { }
|
|
3206
|
+
}
|
|
3207
|
+
});
|
|
3208
|
+
|
|
3209
|
+
tabBar.addEventListener("pointermove", (e) => {
|
|
3210
|
+
if (!tabDrag || e.pointerId !== tabDrag.pointerId) return;
|
|
3211
|
+
e.preventDefault();
|
|
3212
|
+
updateTabDrag(e);
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
tabBar.addEventListener("pointerup", (e) => {
|
|
3216
|
+
if (!tabDrag || e.pointerId !== tabDrag.pointerId) return;
|
|
3217
|
+
finishTabDrag(false);
|
|
3218
|
+
});
|
|
3219
|
+
|
|
3220
|
+
tabBar.addEventListener("pointercancel", (e) => {
|
|
3221
|
+
if (!tabDrag || e.pointerId !== tabDrag.pointerId) return;
|
|
3222
|
+
finishTabDrag(true);
|
|
3223
|
+
});
|
|
3224
|
+
|
|
3225
|
+
// A drag ends over the tab, so the pointerup would otherwise activate it.
|
|
3226
|
+
tabBar.addEventListener("click", (e) => {
|
|
3227
|
+
if (!suppressTabClick) return;
|
|
3228
|
+
suppressTabClick = false;
|
|
3229
|
+
if (e.target.closest(".tab-btn")) {
|
|
3230
|
+
e.stopPropagation();
|
|
3231
|
+
e.preventDefault();
|
|
3232
|
+
}
|
|
3233
|
+
}, true);
|
|
3234
|
+
|
|
2078
3235
|
function openLoadingTab(id, title, opts = {}) {
|
|
2079
3236
|
ensureHomeTab();
|
|
2080
3237
|
ensureMuteButton();
|
|
@@ -2086,7 +3243,7 @@
|
|
|
2086
3243
|
}
|
|
2087
3244
|
|
|
2088
3245
|
const button = createTabButton(id, title, true);
|
|
2089
|
-
|
|
3246
|
+
insertTabButton(button);
|
|
2090
3247
|
|
|
2091
3248
|
const panel = document.createElement("div");
|
|
2092
3249
|
panel.className = "panel game-pane";
|
|
@@ -2101,7 +3258,7 @@
|
|
|
2101
3258
|
|
|
2102
3259
|
const tab = { id, title, button, panel };
|
|
2103
3260
|
tabs.set(id, tab);
|
|
2104
|
-
tabOrder.
|
|
3261
|
+
tabOrder.unshift(id);
|
|
2105
3262
|
if (opts.background) {
|
|
2106
3263
|
setTabStatus(id, "loading");
|
|
2107
3264
|
} else {
|
|
@@ -2831,7 +3988,7 @@ ${icons}
|
|
|
2831
3988
|
}
|
|
2832
3989
|
|
|
2833
3990
|
const button = createTabButton(id, title, true);
|
|
2834
|
-
|
|
3991
|
+
insertTabButton(button);
|
|
2835
3992
|
|
|
2836
3993
|
const panel = document.createElement("div");
|
|
2837
3994
|
panel.className = "panel game-pane";
|
|
@@ -2852,7 +4009,7 @@ ${icons}
|
|
|
2852
4009
|
|
|
2853
4010
|
const tab = { id, title, button, panel };
|
|
2854
4011
|
tabs.set(id, tab);
|
|
2855
|
-
tabOrder.
|
|
4012
|
+
tabOrder.unshift(id);
|
|
2856
4013
|
activateTab(id);
|
|
2857
4014
|
}
|
|
2858
4015
|
|
|
@@ -2866,7 +4023,7 @@ ${icons}
|
|
|
2866
4023
|
}
|
|
2867
4024
|
|
|
2868
4025
|
const button = createTabButton(id, title, true);
|
|
2869
|
-
|
|
4026
|
+
insertTabButton(button);
|
|
2870
4027
|
|
|
2871
4028
|
const panel = document.createElement("div");
|
|
2872
4029
|
panel.className = "panel game-pane static-pane";
|
|
@@ -2907,7 +4064,7 @@ ${icons}
|
|
|
2907
4064
|
|
|
2908
4065
|
const tab = { id, title, button, panel };
|
|
2909
4066
|
tabs.set(id, tab);
|
|
2910
|
-
tabOrder.
|
|
4067
|
+
tabOrder.unshift(id);
|
|
2911
4068
|
activateTab(id);
|
|
2912
4069
|
|
|
2913
4070
|
const code = panel.querySelector("#runner-code");
|