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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/v2.html +1192 -35
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "db-gn",
3
- "version": "1.1.4",
3
+ "version": "1.2.0",
4
4
  "description": "The BEST unblocked gaming website! 2000+ Games.",
5
5
  "main": "index.svg",
6
6
  "scripts": {
package/v2.html CHANGED
@@ -17,8 +17,14 @@
17
17
  --bg1: #2e3244;
18
18
  --bg2: #561a86;
19
19
  --text: #ffffff;
20
- --tab-bg: rgba(0, 0, 0, 0.28);
21
- --tab-active: rgba(176, 0, 230, 0.28);
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")) return;
1350
- const btn = document.createElement("button");
1351
- btn.id = "mute-btn";
1352
- btn.type = "button";
1353
- btn.innerHTML = isMuted ? ICON_SOUND_OFF : ICON_SOUND_ON;
1354
- btn.title = isMuted ? "Unmute All" : "Mute All";
1355
- btn.setAttribute("aria-label", btn.title);
1356
- btn.onclick = toggleMuteAll;
1357
- tabBar.appendChild(btn);
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
- function tabBarAnchor() {
1393
- return document.getElementById("autoclick-btn")
1394
- || document.getElementById("refresh-favicon-btn")
1395
- || document.getElementById("mute-btn");
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
- if (wasActive) {
2046
- let nextId = "home";
2047
- if (idx > 0 && tabOrder[idx - 1]) {
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
- tabBar.insertBefore(button, tabBarAnchor());
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.push(id);
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
- tabBar.insertBefore(button, tabBarAnchor());
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.push(id);
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
- tabBar.insertBefore(button, tabBarAnchor());
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.push(id);
4067
+ tabOrder.unshift(id);
2911
4068
  activateTab(id);
2912
4069
 
2913
4070
  const code = panel.querySelector("#runner-code");