pixelkiln 0.76.0 → 0.78.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.
@@ -39,8 +39,74 @@
39
39
  compare: [],
40
40
  compareZoom: "auto",
41
41
  logs: /* @__PURE__ */ new Set(),
42
- settled: /* @__PURE__ */ new Set()
42
+ settled: /* @__PURE__ */ new Set(),
43
+ playLoops: false,
44
+ backdrop: "checker",
45
+ notify: false,
46
+ fps: null,
47
+ onion: false,
48
+ selecting: false,
49
+ selected: /* @__PURE__ */ new Set()
43
50
  };
51
+ var PREFS_KEY = "pixelkiln.gallery.prefs";
52
+ var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion"];
53
+ function loadPrefs() {
54
+ try {
55
+ const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
56
+ for (const name of PREF_NAMES) if (typeof saved[name] === typeof ui[name]) ui[name] = saved[name];
57
+ } catch {
58
+ }
59
+ applyBackdrop();
60
+ }
61
+ function savePrefs() {
62
+ try {
63
+ localStorage.setItem(PREFS_KEY, JSON.stringify(Object.fromEntries(PREF_NAMES.map((n) => [n, ui[n]]))));
64
+ } catch {
65
+ }
66
+ }
67
+ var HEX = /^#[0-9a-f]{6}$/i;
68
+ function applyBackdrop() {
69
+ const custom = HEX.test(ui.backdrop);
70
+ document.body.dataset.backdrop = custom ? "custom" : ui.backdrop;
71
+ document.body.style.setProperty("--stage-bg", custom ? ui.backdrop : "");
72
+ if (!custom) document.body.style.removeProperty("--stage-bg");
73
+ }
74
+ function backdropControl(onChange) {
75
+ const wrap = el("span", "backdrop");
76
+ wrap.title = "What sprites sit on: judge them against dark, light, or your game's own background";
77
+ const options = [["checker", "Transparency grid"], ["dark", "Dark"], ["light", "Light"]];
78
+ const buttons = [];
79
+ const picker = el("input");
80
+ picker.type = "color";
81
+ picker.setAttribute("aria-label", "Custom backdrop colour");
82
+ const sync = () => {
83
+ for (const b of buttons) b.classList.toggle("on", b.dataset.value === ui.backdrop);
84
+ picker.classList.toggle("on", HEX.test(ui.backdrop));
85
+ if (HEX.test(ui.backdrop)) picker.value = ui.backdrop;
86
+ };
87
+ const set = (value) => {
88
+ ui.backdrop = value;
89
+ applyBackdrop();
90
+ savePrefs();
91
+ for (const other of document.querySelectorAll(".backdrop")) other.__sync?.();
92
+ onChange?.();
93
+ };
94
+ for (const [value, label] of options) {
95
+ const b = el("button", "bd-" + value);
96
+ b.type = "button";
97
+ b.dataset.value = value;
98
+ b.title = label;
99
+ b.setAttribute("aria-label", "Backdrop: " + label);
100
+ b.onclick = () => set(value);
101
+ buttons.push(b);
102
+ wrap.append(b);
103
+ }
104
+ picker.oninput = () => set(picker.value.toLowerCase());
105
+ wrap.append(picker);
106
+ wrap.__sync = sync;
107
+ sync();
108
+ return wrap;
109
+ }
44
110
  var formRegion = () => !ui.editing ? null : /^(new|style|cand):/.test(ui.editing) ? "main" : "drawer";
45
111
  var $ = (id) => document.getElementById(id);
46
112
  var el = (tag, cls, text) => {
@@ -190,7 +256,7 @@
190
256
  };
191
257
  zoomBar.append(b);
192
258
  }
193
- bar.append(zoomBar);
259
+ bar.append(zoomBar, backdropControl());
194
260
  const zoom = ui.compareZoom === "auto" ? auto : ui.compareZoom === "fit" ? Math.min(auto, 1) : Number(ui.compareZoom);
195
261
  const body = el("div", "compare-body");
196
262
  const table = el("table");
@@ -856,6 +922,272 @@
856
922
  return form;
857
923
  }
858
924
 
925
+ // src/gallery/client/src/motion.ts
926
+ var loops = /* @__PURE__ */ new Map();
927
+ var raf = null;
928
+ var t0 = 0;
929
+ var seen = typeof IntersectionObserver === "function" ? new IntersectionObserver((entries) => {
930
+ for (const e of entries) {
931
+ const loop = loops.get(e.target);
932
+ if (loop) loop.visible = e.isIntersecting;
933
+ }
934
+ wake();
935
+ }) : null;
936
+ function registerLoop(img, urls, fps, hoverTarget) {
937
+ if (urls.length < 2) return;
938
+ const loop = { img, urls, fps: fps || 12, index: 0, visible: !seen, hovered: false, first: img.src };
939
+ loops.set(img, loop);
940
+ seen?.observe(img);
941
+ let warmed = false;
942
+ const warm = () => {
943
+ if (warmed) return;
944
+ warmed = true;
945
+ for (const u of urls) {
946
+ const p = new Image();
947
+ p.src = u;
948
+ }
949
+ };
950
+ hoverTarget.addEventListener("pointerenter", () => {
951
+ warm();
952
+ loop.hovered = true;
953
+ wake();
954
+ });
955
+ hoverTarget.addEventListener("pointerleave", () => {
956
+ loop.hovered = false;
957
+ rest(loop);
958
+ });
959
+ hoverTarget.addEventListener("focus", () => {
960
+ warm();
961
+ loop.hovered = true;
962
+ wake();
963
+ });
964
+ hoverTarget.addEventListener("blur", () => {
965
+ loop.hovered = false;
966
+ rest(loop);
967
+ });
968
+ if (ui.playLoops) warm();
969
+ }
970
+ function pruneLoops() {
971
+ for (const [img, loop] of loops) if (!img.isConnected) {
972
+ seen?.unobserve(img);
973
+ loops.delete(img);
974
+ void loop;
975
+ }
976
+ }
977
+ function setPlayLoops(on) {
978
+ ui.playLoops = on;
979
+ if (on) for (const loop of loops.values()) for (const u of loop.urls) {
980
+ const p = new Image();
981
+ p.src = u;
982
+ }
983
+ else for (const loop of loops.values()) if (!loop.hovered) rest(loop);
984
+ wake();
985
+ }
986
+ function rest(loop) {
987
+ if (ui.playLoops) return;
988
+ loop.index = 0;
989
+ if (loop.img.src !== loop.first) loop.img.src = loop.first;
990
+ }
991
+ function wake() {
992
+ if (raf === null && typeof requestAnimationFrame === "function") {
993
+ t0 = performance.now();
994
+ raf = requestAnimationFrame(tick);
995
+ }
996
+ }
997
+ function tick(now) {
998
+ raf = null;
999
+ let active = false;
1000
+ const seconds = Math.max(0, (now - t0) / 1e3);
1001
+ for (const [img, loop] of loops) {
1002
+ if (!img.isConnected) continue;
1003
+ if (!loop.hovered && !(ui.playLoops && loop.visible)) continue;
1004
+ active = true;
1005
+ const i = Math.floor(seconds * loop.fps) % loop.urls.length;
1006
+ if (i !== loop.index) {
1007
+ loop.index = i;
1008
+ img.src = loop.urls[i];
1009
+ }
1010
+ }
1011
+ if (active && !document.hidden) raf = requestAnimationFrame(tick);
1012
+ }
1013
+ document.addEventListener("visibilitychange", () => {
1014
+ if (!document.hidden) wake();
1015
+ });
1016
+
1017
+ // src/gallery/client/src/selection.ts
1018
+ var selectedItems = () => S.snap.items.filter((i) => ui.selected.has(i.id));
1019
+ var regenerable = (i) => i.declared && i.currentSpecHash !== null && i.state === "ok";
1020
+ function setSelecting(on) {
1021
+ ui.selecting = on;
1022
+ if (!on) ui.selected.clear();
1023
+ $("select").textContent = on ? "Done selecting" : "Select";
1024
+ $("select").classList.toggle("on", on);
1025
+ render();
1026
+ }
1027
+ function toggleSelected(id) {
1028
+ if (!ui.selecting) {
1029
+ ui.selecting = true;
1030
+ $("select").textContent = "Done selecting";
1031
+ $("select").classList.add("on");
1032
+ }
1033
+ if (ui.selected.has(id)) ui.selected.delete(id);
1034
+ else ui.selected.add(id);
1035
+ const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
1036
+ if (c) {
1037
+ c.classList.toggle("selected", ui.selected.has(id));
1038
+ c.setAttribute("aria-pressed", String(ui.selected.has(id)));
1039
+ }
1040
+ if (!document.querySelector(".card .sel")) render();
1041
+ else {
1042
+ refreshSelectAll();
1043
+ renderSelectionBar();
1044
+ }
1045
+ }
1046
+ function selectAllButton(items) {
1047
+ const b = el("button", "select-all");
1048
+ b.type = "button";
1049
+ const ids = items.map((i) => i.id);
1050
+ b.__ids = ids;
1051
+ const label = () => {
1052
+ b.textContent = ids.every((id) => ui.selected.has(id)) ? "Clear " + ids.length : "Select all " + ids.length;
1053
+ };
1054
+ b.__label = label;
1055
+ label();
1056
+ b.onclick = () => {
1057
+ const all = ids.every((id) => ui.selected.has(id));
1058
+ for (const id of ids) {
1059
+ if (all) ui.selected.delete(id);
1060
+ else ui.selected.add(id);
1061
+ }
1062
+ for (const id of ids) {
1063
+ const c = document.querySelector('.card[data-key="' + CSS.escape(id) + '"]');
1064
+ if (c) {
1065
+ c.classList.toggle("selected", ui.selected.has(id));
1066
+ c.setAttribute("aria-pressed", String(ui.selected.has(id)));
1067
+ }
1068
+ }
1069
+ refreshSelectAll();
1070
+ renderSelectionBar();
1071
+ };
1072
+ return b;
1073
+ }
1074
+ function refreshSelectAll() {
1075
+ for (const b of document.querySelectorAll(".select-all")) b.__label?.();
1076
+ }
1077
+ function renderSelectionBar() {
1078
+ const bar = $("selbar");
1079
+ bar.textContent = "";
1080
+ for (const id of [...ui.selected]) if (!S.snap.items.some((i) => i.id === id)) ui.selected.delete(id);
1081
+ bar.hidden = !ui.selecting;
1082
+ if (!ui.selecting) return;
1083
+ const items = selectedItems();
1084
+ const projects = new Set(items.map((i) => i.project));
1085
+ const oneProject = projects.size <= 1;
1086
+ const project = items.length ? items[0].project : null;
1087
+ bar.append(el("b", null, items.length ? items.length + " selected" : "Click cards to select them"));
1088
+ const acts = el("div", "acts");
1089
+ const add = (label, run, opts = {}) => {
1090
+ const b = el("button", opts.primary ? "primary" : null, label);
1091
+ b.type = "button";
1092
+ b.disabled = Boolean(opts.disabled);
1093
+ if (opts.title) b.title = opts.title;
1094
+ b.onclick = run;
1095
+ acts.append(b);
1096
+ return b;
1097
+ };
1098
+ const crossProject = "Select within one project: a job and an edit each belong to one";
1099
+ if (GENERATION) {
1100
+ const todo = items.filter(actionable);
1101
+ if (todo.length) add("Generate " + todo.length + " · " + priceOf(todo), () => generateDialog(todo, { project }), { primary: true, disabled: !oneProject, title: oneProject ? "One job: parents first, then everything drawn from them" : crossProject });
1102
+ const redo = items.filter(regenerable);
1103
+ if (redo.length) add("Regenerate " + redo.length + " · " + priceOf(redo), () => generateDialog(redo, { project, force: true }), { disabled: !oneProject, title: oneProject ? "Replace these with new generations; each old one stays in its history" : crossProject });
1104
+ }
1105
+ const shown = items.filter((i) => i.outputs.some((o) => o.url));
1106
+ if (shown.length >= 2) {
1107
+ add("Compare" + (shown.length > 4 ? " first 4" : ""), () => {
1108
+ ui.compare = shown.slice(0, 4).map((i) => i.id);
1109
+ openCompare();
1110
+ });
1111
+ }
1112
+ if (EDITABLE && items.length) {
1113
+ const taggable = items.filter((i) => i.declaredAs && projectOf(i)?.manifestSha256);
1114
+ const tag = el("input");
1115
+ tag.type = "text";
1116
+ tag.placeholder = "tag";
1117
+ tag.setAttribute("aria-label", "Tag to add or remove");
1118
+ tag.pattern = "[^,]+";
1119
+ const addTag = el("button", null, "Add tag");
1120
+ addTag.type = "button";
1121
+ const dropTag = el("button", null, "Remove tag");
1122
+ dropTag.type = "button";
1123
+ addTag.disabled = dropTag.disabled = !taggable.length || !oneProject;
1124
+ addTag.title = dropTag.title = !oneProject ? crossProject : taggable.length < items.length ? "Undeclared records have no manifest entry to tag" : "One manifest write for every selected asset";
1125
+ const run = (adding) => retag(taggable, tag.value.trim(), adding, tag);
1126
+ addTag.onclick = () => run(true);
1127
+ dropTag.onclick = () => run(false);
1128
+ tag.onkeydown = (e) => {
1129
+ if (e.key === "Enter") {
1130
+ e.preventDefault();
1131
+ run(true);
1132
+ }
1133
+ };
1134
+ const box = el("span", "tagbox");
1135
+ box.append(tag, addTag, dropTag);
1136
+ acts.append(box);
1137
+ }
1138
+ add("Clear", () => {
1139
+ ui.selected.clear();
1140
+ render();
1141
+ }, { disabled: !items.length });
1142
+ add("Done", () => setSelecting(false));
1143
+ bar.append(acts);
1144
+ const msg = el("span", "msg");
1145
+ msg.id = "selmsg";
1146
+ bar.append(msg);
1147
+ }
1148
+ async function retag(items, tag, adding, input) {
1149
+ const msg = $("selmsg");
1150
+ if (!tag || tag.includes(",")) {
1151
+ msg.className = "msg bad";
1152
+ msg.textContent = "Type one tag (no commas).";
1153
+ input.focus();
1154
+ return;
1155
+ }
1156
+ const byId = /* @__PURE__ */ new Map();
1157
+ for (const i of items) {
1158
+ if (byId.has(i.declaredAs)) continue;
1159
+ const now = i.asset && i.asset.tags || [];
1160
+ const next = adding ? now.includes(tag) ? now : [...now, tag] : now.filter((t) => t !== tag);
1161
+ if (next.length !== now.length) byId.set(i.declaredAs, next);
1162
+ }
1163
+ if (!byId.size) {
1164
+ msg.className = "msg";
1165
+ msg.textContent = adding ? "They all have it already." : "None of them has it.";
1166
+ return;
1167
+ }
1168
+ const pr = projectOf(items[0]);
1169
+ const body = {
1170
+ action: "batch",
1171
+ expectedSha256: pr.manifestSha256,
1172
+ edits: [...byId].map(([assetId, tags]) => ({ action: "patch-asset", assetId, patch: { tags } }))
1173
+ };
1174
+ if (items[0].project) body.project = items[0].project;
1175
+ msg.className = "msg";
1176
+ msg.textContent = "saving…";
1177
+ try {
1178
+ S.snap = await postEdit(body);
1179
+ render();
1180
+ const after = $("selmsg");
1181
+ if (after) {
1182
+ after.className = "msg ok";
1183
+ after.textContent = (adding ? "Tagged " : "Untagged ") + byId.size + (byId.size === 1 ? " asset" : " assets") + " “" + tag + "”.";
1184
+ }
1185
+ } catch (err) {
1186
+ msg.className = "msg bad";
1187
+ msg.textContent = err.status === 409 ? "The manifest changed on disk; refresh and try again." : err.message;
1188
+ }
1189
+ }
1190
+
859
1191
  // src/gallery/client/src/grid.ts
860
1192
  function visibleItems() {
861
1193
  const q = ui.q.trim().toLowerCase();
@@ -1031,7 +1363,9 @@
1031
1363
  }
1032
1364
  function card(item) {
1033
1365
  const slot = ui.compare.indexOf(item.id);
1034
- const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : ""));
1366
+ const picked = ui.selected.has(item.id);
1367
+ const c = el("button", "card" + (item.outputs.some((o) => o.url) ? "" : " ghost") + (ui.open === item.id ? " active" : "") + (slot >= 0 ? " compared" : "") + (picked ? " selected" : ""));
1368
+ if (ui.selecting) c.setAttribute("aria-pressed", String(picked));
1035
1369
  c.type = "button";
1036
1370
  c.dataset.key = item.id;
1037
1371
  c.setAttribute("aria-label", item.id + ", " + item.state + (slot >= 0 ? ", in comparison" : ""));
@@ -1060,15 +1394,21 @@
1060
1394
  cell.append(badge);
1061
1395
  }
1062
1396
  c.append(cell, body);
1397
+ if (ui.selecting) cell.append(el("span", "sel", "✓"));
1063
1398
  c.onclick = (e) => {
1064
1399
  if (e.shiftKey) toggleCompare(item.id);
1400
+ else if (ui.selecting || e.metaKey || e.ctrlKey) toggleSelected(item.id);
1065
1401
  else openItem(item.id);
1066
1402
  };
1403
+ const still = cell.querySelector(":scope > img");
1404
+ const shown = item.edit && item.editStatus === "edited" && item.edits.every((e) => e.url) ? item.edits : item.outputs.filter((o) => o.url);
1405
+ if (still && isFrameSet(item) && shown.length > 1) registerLoop(still, shown.map((o) => o.url), item.fps || 12, c);
1067
1406
  return c;
1068
1407
  }
1069
1408
  function renderMain(items) {
1070
1409
  const root = $("root");
1071
1410
  root.textContent = "";
1411
+ pruneLoops();
1072
1412
  if (!S.snap.items.length) {
1073
1413
  const e = el("div", "empty");
1074
1414
  e.append(el("p", null, "Nothing has been generated for this project yet."));
@@ -1093,7 +1433,7 @@
1093
1433
  const sections = [];
1094
1434
  for (const pr of projects) {
1095
1435
  const inProject = pr ? shown.filter((i) => i.project === pr.id) : shown;
1096
- const styleSections = ui.group === "style" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
1436
+ const styleSections = ui.group === "style" || ui.group === "family" ? S.snap.styles.filter((s) => !pr || s.project === pr.id).map((s) => ({ style: s, items: inProject.filter((i) => i.styleId === s.id) })).filter((s) => s.items.length) : inProject.length ? [{ style: null, items: inProject }] : [];
1097
1437
  if (pr) sections.push({ project: pr, style: null, items: null, count: inProject.length });
1098
1438
  sections.push(...styleSections);
1099
1439
  }
@@ -1150,6 +1490,7 @@
1150
1490
  r.onclick = () => generateDialog(refreshable, { project: s.project, refresh: true });
1151
1491
  tools.append(r);
1152
1492
  }
1493
+ if (ui.selecting && ui.group !== "family") tools.append(selectAllButton(sec.items));
1153
1494
  const styleKey = "style:" + (s.project || "") + ":" + s.id;
1154
1495
  if (EDITABLE && s.outDir) {
1155
1496
  const es = el("button", null, ui.editing === styleKey ? "Cancel" : "Edit style");
@@ -1174,9 +1515,12 @@
1174
1515
  if (ui.editing === styleKey) wrap.append(styleForm(s));
1175
1516
  if (ui.editing === addKey) wrap.append(addAssetForm(s));
1176
1517
  }
1177
- const grid = el("div", "grid");
1178
- for (const item of sec.items) grid.append(card(item));
1179
- wrap.append(grid);
1518
+ if (ui.group === "family") wrap.append(...familyGroups(sec.items));
1519
+ else {
1520
+ const grid = el("div", "grid");
1521
+ for (const item of sec.items) grid.append(card(item));
1522
+ wrap.append(grid);
1523
+ }
1180
1524
  root.append(wrap);
1181
1525
  }
1182
1526
  if (hidden) {
@@ -1192,6 +1536,93 @@
1192
1536
  root.append(more);
1193
1537
  }
1194
1538
  }
1539
+ var KIND_ORDER = { base: 0, state: 1, animation: 2, portrait: 3, outfit: 4 };
1540
+ var TURN = ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"];
1541
+ function familyOrder(a, b) {
1542
+ const ka = KIND_ORDER[a.character.kind] ?? 9, kb = KIND_ORDER[b.character.kind] ?? 9;
1543
+ if (ka !== kb) return ka - kb;
1544
+ if (a.character.kind === "animation") {
1545
+ const la = loopName(a), lb = loopName(b);
1546
+ if (la !== lb) return la < lb ? -1 : 1;
1547
+ return TURN.indexOf(a.character.direction) - TURN.indexOf(b.character.direction);
1548
+ }
1549
+ return a.assetId < b.assetId ? -1 : a.assetId > b.assetId ? 1 : 0;
1550
+ }
1551
+ function familyGroups(items) {
1552
+ const families = /* @__PURE__ */ new Map();
1553
+ const loose = [];
1554
+ for (const item of items) {
1555
+ const root = item.character ? familyRoot(item) : null;
1556
+ if (!root) {
1557
+ loose.push(item);
1558
+ continue;
1559
+ }
1560
+ if (!families.has(root.id)) families.set(root.id, { root, members: [] });
1561
+ families.get(root.id).members.push(item);
1562
+ }
1563
+ const out = [];
1564
+ for (const { root, members } of families.values()) {
1565
+ members.sort(familyOrder);
1566
+ const group = el("div", "fam-group");
1567
+ const head = el("div", "fam-head");
1568
+ head.append(el("b", "mono", root.assetId));
1569
+ const counts = {};
1570
+ for (const m of members) counts[m.state] = (counts[m.state] || 0) + 1;
1571
+ const tally = el("span", "fam-tally");
1572
+ const loops2 = new Set(members.filter((m) => m.character.kind === "animation").map(loopName)).size;
1573
+ tally.append(document.createTextNode(members.length + (members.length === 1 ? " asset" : " assets") + (loops2 ? " · " + loops2 + (loops2 === 1 ? " loop" : " loops") : "")));
1574
+ for (const [state, n] of Object.entries(counts)) {
1575
+ const t = el("span", "st");
1576
+ t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
1577
+ tally.append(t);
1578
+ }
1579
+ head.append(tally);
1580
+ const tools = el("div", "tools");
1581
+ if (ui.selecting) tools.append(selectAllButton(members));
1582
+ const view = el("button", null, "Family view");
1583
+ view.type = "button";
1584
+ view.onclick = () => openFamily(root);
1585
+ tools.append(view);
1586
+ const everything = familyMembers(root);
1587
+ const todo = everything.filter(actionable);
1588
+ if (GENERATION && todo.length) {
1589
+ const g = el("button", "primary", "Generate " + todo.length + " · " + priceOf(todo));
1590
+ g.type = "button";
1591
+ g.title = "One job for the family: parents first, then everything drawn from them";
1592
+ g.onclick = () => generateDialog(todo, { project: root.project });
1593
+ tools.append(g);
1594
+ }
1595
+ head.append(tools);
1596
+ const grid = el("div", "grid");
1597
+ for (const m of members) grid.append(card(m));
1598
+ group.append(head, grid);
1599
+ out.push(group);
1600
+ }
1601
+ if (loose.length) {
1602
+ const group = el("div", "fam-group loose");
1603
+ if (families.size) {
1604
+ const head = el("div", "fam-head");
1605
+ head.append(el("b", null, "Not in a family"), el("span", "fam-tally", loose.length + (loose.length === 1 ? " asset" : " assets")));
1606
+ if (ui.selecting) {
1607
+ const tools = el("div", "tools");
1608
+ tools.append(selectAllButton(loose));
1609
+ head.append(tools);
1610
+ }
1611
+ group.append(head);
1612
+ }
1613
+ const grid = el("div", "grid");
1614
+ for (const item of loose) grid.append(card(item));
1615
+ group.append(grid);
1616
+ out.push(group);
1617
+ }
1618
+ return out;
1619
+ }
1620
+ var actionable = (item) => item.declared && item.currentSpecHash !== null && ["missing", "stale", "failed"].includes(item.state);
1621
+ function priceOf(items) {
1622
+ const totals = {};
1623
+ for (const i of items) if (i.estimatedCost !== null) totals[i.costUnit] = (totals[i.costUnit] || 0) + i.estimatedCost;
1624
+ return Object.entries(totals).map(([unit, n]) => fmtCost(unit, Math.round(n * 100) / 100)).join(" + ") || "free";
1625
+ }
1195
1626
  function projectHeader(pr, count) {
1196
1627
  const head = el("section", "project");
1197
1628
  const line = el("div", "phead");
@@ -1464,6 +1895,7 @@
1464
1895
  if (!ACTIVE_PHASES.has(job.phase) && !ui.settled.has(job.id)) {
1465
1896
  ui.settled.add(job.id);
1466
1897
  changed = true;
1898
+ notifyJob(job);
1467
1899
  const open = ui.open && S.snap.items.find((i) => i.id === ui.open);
1468
1900
  if (open && job.project === open.project && job.keys.includes(open.key)) {
1469
1901
  ui.notice = { id: open.id, text: job.phase === "failed" ? (JOB_VERB[job.mode] || job.mode).replace(/ing$/, "ing") + " this asset failed: " + (job.error || "see the log above") : job.mode === "generate" ? "Regenerated. The new result is on disk and recorded as this generation." : job.mode === "restore" ? "Restored. The recorded bytes are back on disk." : job.mode === "revert" ? "Brought back generation #" + job.revert + ". The one it replaced is now #1 in the list below." : job.mode === "refresh" ? job.counts.downloaded ? "Pulled the upstream change; the lockfile records the new bytes." : "Nothing changed upstream; the file was left alone." : job.mode === "resume" && job.counts.downloaded ? "Done. The file on disk and the record now match the manifest." : "Done." };
@@ -1472,6 +1904,7 @@
1472
1904
  if (job.phase === "review" && !ui.settled.has(job.id + ":review")) {
1473
1905
  ui.settled.add(job.id + ":review");
1474
1906
  changed = true;
1907
+ notifyJob(job);
1475
1908
  }
1476
1909
  }
1477
1910
  if (changed) refresh();
@@ -1489,6 +1922,34 @@
1489
1922
  clearTimeout(jobsTimer ?? void 0);
1490
1923
  if (active) jobsTimer = setTimeout(pollJobs, 2e3);
1491
1924
  }
1925
+ var DONE_VERB = { generate: "Generated", resume: "Resumed", refresh: "Pulled upstream changes for", restore: "Restored", revert: "Brought back a generation of" };
1926
+ async function enableNotifications() {
1927
+ if (typeof Notification === "undefined") return false;
1928
+ if (Notification.permission === "granted") return true;
1929
+ if (Notification.permission === "denied") {
1930
+ $("note").textContent = " Notifications are blocked for this page in the browser's site settings.";
1931
+ return false;
1932
+ }
1933
+ try {
1934
+ return await Notification.requestPermission() === "granted";
1935
+ } catch {
1936
+ return false;
1937
+ }
1938
+ }
1939
+ function notifyJob(job) {
1940
+ if (!ui.notify || !document.hidden || typeof Notification === "undefined" || Notification.permission !== "granted") return;
1941
+ const n = job.keys.length;
1942
+ const what = n === 1 ? job.keys[0] : n + " assets";
1943
+ const body = job.phase === "review" ? job.review.length + " waiting for you to pick a candidate" : job.phase === "failed" ? "failed: " + (job.error || "see the log") : (DONE_VERB[job.mode] || "Finished") + " " + what;
1944
+ try {
1945
+ const note = new Notification("pixelkiln" + (job.project ? " · " + job.project : ""), { body, tag: "pixelkiln-job-" + job.id });
1946
+ note.onclick = () => {
1947
+ window.focus();
1948
+ note.close();
1949
+ };
1950
+ } catch {
1951
+ }
1952
+ }
1492
1953
  function renderJobs() {
1493
1954
  const host = $("jobs");
1494
1955
  host.textContent = "";
@@ -2135,6 +2596,7 @@
2135
2596
  var SPEEDS = [0.5, 1, 2];
2136
2597
  var SPIN_MS = 420;
2137
2598
  var DRAG_STEP_PX = 28;
2599
+ var CELL_PX = 68;
2138
2600
  var F = null;
2139
2601
  var reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
2140
2602
  var byKey = (project, key) => S.snap.items.find((i) => i.key === key && i.project === project);
@@ -2243,7 +2705,7 @@
2243
2705
  t.append(el("i", "dot " + STATE_TONE[state]), document.createTextNode(n + " " + state));
2244
2706
  tally.append(t);
2245
2707
  }
2246
- bar.append(tally);
2708
+ bar.append(tally, backdropControl());
2247
2709
  if (GENERATION) {
2248
2710
  const todo = members.filter((m) => m.declared && m.currentSpecHash !== null && ["missing", "stale", "failed"].includes(m.state));
2249
2711
  if (todo.length) {
@@ -2270,7 +2732,7 @@
2270
2732
  host.append(scrim, panel);
2271
2733
  panel.scrollTop = scroll;
2272
2734
  highlight(F.direction);
2273
- if (F.raf === null) F.raf = requestAnimationFrame(tick);
2735
+ if (F.raf === null) F.raf = requestAnimationFrame(tick2);
2274
2736
  }
2275
2737
  function turntable(root, turnables, source, order) {
2276
2738
  const wrap = el("section", "turntable");
@@ -2407,11 +2869,11 @@
2407
2869
  return wrap;
2408
2870
  }
2409
2871
  function loopGrid(root, members, order) {
2410
- const loops = members.filter((m) => m.character.kind === "animation");
2872
+ const loops2 = members.filter((m) => m.character.kind === "animation");
2411
2873
  const wrap = el("section", "loop-grid");
2412
2874
  const head = el("div", "lg-head");
2413
- head.append(el("h3", null, loops.length ? "Loops" : "No loops yet"));
2414
- if (loops.length) {
2875
+ head.append(el("h3", null, loops2.length ? "Loops" : "No loops yet"));
2876
+ if (loops2.length) {
2415
2877
  const play = el("button", null, F.playing ? "Pause" : "Play");
2416
2878
  play.type = "button";
2417
2879
  play.onclick = () => {
@@ -2431,14 +2893,14 @@
2431
2893
  head.append(speed);
2432
2894
  }
2433
2895
  wrap.append(head);
2434
- if (!loops.length) {
2896
+ if (!loops2.length) {
2435
2897
  wrap.append(el("p", "state-dim", EDITABLE ? "Add one from the base's drawer (+ New animation), or draft a whole character in the studio." : "None declared."));
2436
2898
  return wrap;
2437
2899
  }
2438
- const used = /* @__PURE__ */ new Set([...order, ...loops.map((l) => l.character.direction).filter(Boolean)]);
2900
+ const used = /* @__PURE__ */ new Set([...order, ...loops2.map((l) => l.character.direction).filter(Boolean)]);
2439
2901
  const columns = COMPASS.filter((d) => used.has(d));
2440
2902
  const rows = /* @__PURE__ */ new Map();
2441
- for (const l of loops) {
2903
+ for (const l of loops2) {
2442
2904
  const k = (l.character.parentKey || "") + "|" + loopName(l);
2443
2905
  if (!rows.has(k)) rows.set(k, []);
2444
2906
  rows.get(k).push(l);
@@ -2505,6 +2967,7 @@
2505
2967
  img.alt = item.assetId;
2506
2968
  const urls = frameUrls(item);
2507
2969
  if (urls.length) {
2970
+ fitCell(img, item.width, item.height);
2508
2971
  img.src = urls[0];
2509
2972
  for (const u of urls) {
2510
2973
  const p = new Image();
@@ -2514,6 +2977,7 @@
2514
2977
  } else {
2515
2978
  const ghost = rotationUrl(parent, direction);
2516
2979
  if (ghost) {
2980
+ fitCell(img, parent.width, parent.height);
2517
2981
  img.src = ghost;
2518
2982
  b.classList.add("ghost");
2519
2983
  } else img.hidden = true;
@@ -2539,6 +3003,7 @@
2539
3003
  img.alt = item.assetId;
2540
3004
  const urls = frameUrls(item);
2541
3005
  if (urls.length) {
3006
+ fitCell(img, item.width, item.height);
2542
3007
  img.src = urls[0];
2543
3008
  if (urls.length > 1) F.anims.push({ img, urls, fps: item.fps || 12, index: 0 });
2544
3009
  } else img.hidden = true;
@@ -2551,11 +3016,16 @@
2551
3016
  wrap.append(strip);
2552
3017
  return wrap;
2553
3018
  }
3019
+ function fitCell(img, width, height) {
3020
+ const s = displayScale(width, height, CELL_PX, CELL_PX);
3021
+ img.width = Math.max(1, Math.round(width * s));
3022
+ img.height = Math.max(1, Math.round(height * s));
3023
+ }
2554
3024
  function highlight(direction) {
2555
3025
  if (!F) return;
2556
3026
  for (const [d, nodes] of F.columns) for (const n of nodes) n.classList.toggle("col-on", d === direction);
2557
3027
  }
2558
- function tick(now) {
3028
+ function tick2(now) {
2559
3029
  if (!F) return;
2560
3030
  if (!F.panel || !F.panel.isConnected) {
2561
3031
  stopFamily();
@@ -2577,7 +3047,7 @@
2577
3047
  const order = source ? turnOrder(source) : COMPASS;
2578
3048
  F.turn(order[(order.indexOf(F.direction) + 1) % order.length]);
2579
3049
  }
2580
- F.raf = requestAnimationFrame(tick);
3050
+ F.raf = requestAnimationFrame(tick2);
2581
3051
  }
2582
3052
  var familyState = () => F && { rootId: F.rootId, source: F.source, direction: F.direction, playing: F.playing, anims: F.anims.length };
2583
3053
 
@@ -2932,11 +3402,11 @@
2932
3402
  for (const [i, pose] of [set.firstFrameKeypoints, ...set.frames].entries()) {
2933
3403
  const where = i === 0 ? "the starting pose" : "frame " + i;
2934
3404
  if (!Array.isArray(pose) || pose.length !== 18) return where + " needs all 18 joints";
2935
- const seen = /* @__PURE__ */ new Set();
3405
+ const seen2 = /* @__PURE__ */ new Set();
2936
3406
  for (const j of pose) {
2937
3407
  if (!SKELETON_LABELS.includes(j && j.label)) return where + ': "' + (j && j.label) + `" is not one of PixelLab's 18 joints`;
2938
- if (seen.has(j.label)) return where + ': "' + j.label + '" appears twice';
2939
- seen.add(j.label);
3408
+ if (seen2.has(j.label)) return where + ': "' + j.label + '" appears twice';
3409
+ seen2.add(j.label);
2940
3410
  if (!(j.x >= 0 && j.x <= 1 && j.y >= 0 && j.y <= 1)) return where + ": " + j.label + " must sit inside the image (x and y 0 to 1)";
2941
3411
  }
2942
3412
  }
@@ -3255,25 +3725,34 @@
3255
3725
  host.append(preview);
3256
3726
  return;
3257
3727
  }
3258
- const member = shown[Math.min(ui.member, shown.length - 1)] || shown[0];
3259
- const frameSet = isFrameSet(item);
3728
+ let idx = Math.min(ui.member, shown.length - 1);
3729
+ const frameSet = isFrameSet(item) && shown.length > 1;
3260
3730
  const w = item.width, h = item.height;
3261
3731
  const box = Math.min(window.innerWidth, 540) - 62;
3262
3732
  const auto = displayScale(w, h, box, Math.min(window.innerHeight * 0.55, 560));
3263
3733
  const zoom = ui.zoom === "auto" ? auto : ui.zoom === "fit" ? Math.min(auto, 1) : Number(ui.zoom);
3264
- const img = el("img");
3265
- img.src = member.url;
3266
- img.alt = member.role || item.assetId;
3267
- if (zoom >= 1) {
3268
- img.width = Math.round(w * zoom);
3269
- img.height = Math.round(h * zoom);
3270
- } else {
3271
- img.style.maxWidth = "100%";
3272
- img.style.maxHeight = "55vh";
3273
- img.style.width = "auto";
3274
- img.style.height = "auto";
3275
- }
3276
- preview.append(img);
3734
+ const sized = (img2) => {
3735
+ if (zoom >= 1) {
3736
+ img2.width = Math.round(w * zoom);
3737
+ img2.height = Math.round(h * zoom);
3738
+ } else {
3739
+ img2.style.maxWidth = "100%";
3740
+ img2.style.maxHeight = "55vh";
3741
+ img2.style.width = "auto";
3742
+ img2.style.height = "auto";
3743
+ }
3744
+ return img2;
3745
+ };
3746
+ const stack = el("div", "stack");
3747
+ const img = sized(el("img"));
3748
+ img.src = shown[idx].url;
3749
+ img.alt = shown[idx].role || item.assetId;
3750
+ const onion = sized(el("img", "onion"));
3751
+ onion.alt = "";
3752
+ onion.setAttribute("aria-hidden", "true");
3753
+ onion.hidden = !(frameSet && ui.onion);
3754
+ stack.append(onion, img);
3755
+ preview.append(stack);
3277
3756
  const zoomBar = el("div", "zoom");
3278
3757
  zoomBar.append(el("span", null, "zoom"));
3279
3758
  for (const z of ["fit", 1, 2, 4, 8]) {
@@ -3286,34 +3765,98 @@
3286
3765
  zoomBar.append(b);
3287
3766
  }
3288
3767
  const label = el("span", "grow", w + "×" + h + (zoom >= 1 ? " at " + zoom + "×" : " fitted"));
3289
- zoomBar.append(label);
3290
- if (frameSet && shown.length > 1) {
3291
- const fps = item.fps || 12;
3292
- const play = el("button", null, "Play " + fps + " fps");
3768
+ zoomBar.append(label, backdropControl());
3769
+ host.append(preview, zoomBar);
3770
+ let strip = null;
3771
+ const show = (i) => {
3772
+ idx = (i + shown.length) % shown.length;
3773
+ ui.member = idx;
3774
+ img.src = shown[idx].url;
3775
+ img.alt = shown[idx].role || item.assetId;
3776
+ if (frameSet) {
3777
+ onion.src = shown[(idx - 1 + shown.length) % shown.length].url;
3778
+ scrub.value = String(idx);
3779
+ count.textContent = idx + 1 + " / " + shown.length;
3780
+ }
3781
+ if (strip) for (const [j, b] of [...strip.querySelectorAll("button")].entries()) b.classList.toggle("on", j === idx);
3782
+ };
3783
+ const scrub = el("input");
3784
+ const count = el("span", "count");
3785
+ if (frameSet) {
3786
+ const bar = el("div", "frames-bar");
3787
+ const own = item.fps || 12;
3788
+ const fps = () => ui.fps || own;
3789
+ const prev = el("button", null, "◀");
3790
+ prev.type = "button";
3791
+ prev.title = "Previous frame (,)";
3792
+ const next = el("button", null, "▶");
3793
+ next.type = "button";
3794
+ next.title = "Next frame (.)";
3795
+ const play = el("button", "play", "Play");
3293
3796
  play.type = "button";
3294
- let idx = shown.indexOf(member);
3295
- play.onclick = () => {
3296
- if (ui.playing) {
3297
- stopPlayback();
3298
- play.textContent = "Play " + fps + " fps";
3299
- return;
3300
- }
3797
+ play.title = "Play or pause (space)";
3798
+ const pause = () => {
3799
+ stopPlayback();
3800
+ play.textContent = "Play";
3801
+ };
3802
+ const start = () => {
3803
+ stopPlayback();
3301
3804
  play.textContent = "Pause";
3302
3805
  ui.playing = setInterval(() => {
3303
- if (document.hidden) return;
3304
- idx = (idx + 1) % shown.length;
3305
- img.src = shown[idx].url;
3306
- label.textContent = (shown[idx].role || "frame " + (idx + 1)) + " · " + w + "×" + h + (zoom >= 1 ? " at " + zoom + "×" : "");
3307
- for (const [i, b] of [...host.querySelectorAll(".members button")].entries()) b.classList.toggle("on", i === idx);
3308
- }, Math.max(16, Math.round(1e3 / fps)));
3806
+ if (!document.hidden) show(idx + 1);
3807
+ }, Math.max(16, Math.round(1e3 / fps())));
3808
+ };
3809
+ play.onclick = () => ui.playing ? pause() : start();
3810
+ prev.onclick = () => {
3811
+ pause();
3812
+ show(idx - 1);
3309
3813
  };
3310
- zoomBar.append(play);
3814
+ next.onclick = () => {
3815
+ pause();
3816
+ show(idx + 1);
3817
+ };
3818
+ scrub.type = "range";
3819
+ scrub.min = "0";
3820
+ scrub.max = String(shown.length - 1);
3821
+ scrub.step = "1";
3822
+ scrub.setAttribute("aria-label", "Frame");
3823
+ scrub.oninput = () => {
3824
+ pause();
3825
+ show(Number(scrub.value));
3826
+ };
3827
+ const speed = el("select");
3828
+ speed.setAttribute("aria-label", "Frames per second");
3829
+ const rates = [.../* @__PURE__ */ new Set([own, 4, 6, 8, 10, 12, 15, 24])].sort((a, b) => a - b);
3830
+ for (const r of rates) speed.append(new Option(r + " fps" + (r === own ? " (its own)" : ""), String(r)));
3831
+ speed.value = String(fps());
3832
+ speed.onchange = () => {
3833
+ ui.fps = Number(speed.value) === own ? null : Number(speed.value);
3834
+ if (ui.playing) start();
3835
+ };
3836
+ const onionBox = el("label", "chip");
3837
+ const onionToggle = el("input");
3838
+ onionToggle.type = "checkbox";
3839
+ onionToggle.checked = ui.onion;
3840
+ onionToggle.onchange = () => {
3841
+ ui.onion = onionToggle.checked;
3842
+ onion.hidden = !ui.onion;
3843
+ savePrefs();
3844
+ };
3845
+ onionBox.title = "Show the previous frame faintly under this one";
3846
+ onionBox.append(onionToggle, document.createTextNode(" onion skin"));
3847
+ bar.append(prev, play, next, scrub, count, speed, onionBox);
3848
+ host.append(bar);
3849
+ host.__frames = { step: (n) => {
3850
+ pause();
3851
+ show(idx + n);
3852
+ }, toggle: () => play.click() };
3853
+ } else {
3854
+ host.__frames = null;
3311
3855
  }
3312
- host.append(preview, zoomBar);
3313
3856
  if (shown.length > 1) {
3314
- const strip = el("div", "members");
3857
+ strip = el("div", "members");
3315
3858
  shown.forEach((o, i) => {
3316
- const b = el("button", o === member ? "on" : null);
3859
+ const b = el("button", i === idx ? "on" : null);
3317
3860
  b.type = "button";
3318
3861
  const t = el("img");
3319
3862
  t.src = o.url;
@@ -3321,13 +3864,21 @@
3321
3864
  t.loading = "lazy";
3322
3865
  b.append(t, el("span", null, o.role || String(i + 1)));
3323
3866
  b.onclick = () => {
3324
- ui.member = i;
3325
- renderPreview(item, host);
3867
+ if (frameSet) {
3868
+ stopPlayback();
3869
+ }
3870
+ show(i);
3871
+ if (!frameSet) renderPreview(item, host);
3326
3872
  };
3327
3873
  strip.append(b);
3328
3874
  });
3329
3875
  host.append(strip);
3330
3876
  }
3877
+ show(idx);
3878
+ }
3879
+ function drawerFrames() {
3880
+ const host = document.querySelector(".drawer .dbody > div");
3881
+ return host ? host.__frames ?? null : null;
3331
3882
  }
3332
3883
  var CHARACTER_KIND = {
3333
3884
  base: "base",
@@ -3698,7 +4249,7 @@
3698
4249
  ui.projects = new Set(list("project"));
3699
4250
  ui.compare = list("compare").slice(0, 4);
3700
4251
  if (["key", "newest", "oldest", "cost", "size"].includes(params.get("sort") ?? "")) ui.sort = params.get("sort");
3701
- if (["style", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
4252
+ if (["style", "family", "none"].includes(params.get("group") ?? "")) ui.group = params.get("group");
3702
4253
  }
3703
4254
  function writeUrlState() {
3704
4255
  const params = new URLSearchParams();
@@ -3771,6 +4322,7 @@
3771
4322
  renderMain(visibleItems());
3772
4323
  renderDrawer();
3773
4324
  renderTray();
4325
+ renderSelectionBar();
3774
4326
  refreshFamily();
3775
4327
  }
3776
4328
  function renderQuietly() {
@@ -3780,6 +4332,7 @@
3780
4332
  if (region !== "main") renderMain(visibleItems());
3781
4333
  if (region !== "drawer") renderDrawer();
3782
4334
  renderTray();
4335
+ renderSelectionBar();
3783
4336
  refreshFamily();
3784
4337
  }
3785
4338
 
@@ -3907,7 +4460,7 @@
3907
4460
  const refField = field("own sprite (optional)", refInput, "A south-facing sprite at the style's size: PixelLab rotates it instead of drawing one, and only the rotations are billed.");
3908
4461
  refField.append(refPreview);
3909
4462
  baseSection.append(baseRow, field("description", prompt), refField);
3910
- const loops = [];
4463
+ const loops2 = [];
3911
4464
  const loopsSection = el("section", "studio-part");
3912
4465
  const loopList = el("div", "studio-loops");
3913
4466
  const addLoop = el("button", "add", "+ Add a loop");
@@ -3970,7 +4523,7 @@
3970
4523
  newStyle.hidden = !isNewStyle();
3971
4524
  styleId.required = isNewStyle();
3972
4525
  if (!outDirTouched) outDir.value = "art/" + (styleId.value.trim() || "characters");
3973
- for (const row2 of loops) syncLoopDirections(row2);
4526
+ for (const row2 of loops2) syncLoopDirections(row2);
3974
4527
  }
3975
4528
  function syncLoopDirections(row2) {
3976
4529
  const allowed = styleDirections();
@@ -4015,7 +4568,7 @@
4015
4568
  remove.type = "button";
4016
4569
  const row2 = { id, mode, what, directions: boxes, wrap };
4017
4570
  remove.onclick = () => {
4018
- loops.splice(loops.indexOf(row2), 1);
4571
+ loops2.splice(loops2.indexOf(row2), 1);
4019
4572
  wrap.remove();
4020
4573
  schedulePrice();
4021
4574
  };
@@ -4027,7 +4580,7 @@
4027
4580
  line.append(field("loop id", id), field("mode", mode), field("template or motion", what));
4028
4581
  wrap.append(line, dirs, remove);
4029
4582
  loopList.append(wrap);
4030
- loops.push(row2);
4583
+ loops2.push(row2);
4031
4584
  syncLoopMode(row2);
4032
4585
  syncLoopDirections(row2);
4033
4586
  }
@@ -4044,7 +4597,7 @@
4044
4597
  }
4045
4598
  const base = baseId.value.trim();
4046
4599
  out.push({ action: "add-asset", assetId: base, asset: { prompt: prompt.value.trim(), styles: [sid], ...reference ? { reference } : {} } });
4047
- for (const row2 of loops) {
4600
+ for (const row2 of loops2) {
4048
4601
  const chosen = [...row2.directions].filter(([, box]) => box.checked).map(([d]) => d);
4049
4602
  if (!row2.id.value.trim() || !chosen.length) continue;
4050
4603
  const animation = { of: base, directions: chosen };
@@ -4266,7 +4819,23 @@
4266
4819
  }
4267
4820
 
4268
4821
  // src/gallery/client/src/main.ts
4822
+ loadPrefs();
4269
4823
  $("refresh").onclick = refresh;
4824
+ $("select").onclick = () => setSelecting(!ui.selecting);
4825
+ $("playloops").checked = ui.playLoops;
4826
+ $("playloops").onchange = (e) => {
4827
+ setPlayLoops(e.target.checked);
4828
+ savePrefs();
4829
+ };
4830
+ $("backdrop-slot").replaceWith(backdropControl());
4831
+ $("notify-chip").hidden = !GENERATION || typeof Notification === "undefined";
4832
+ $("notify").checked = ui.notify && typeof Notification !== "undefined" && Notification.permission === "granted";
4833
+ $("notify").onchange = async (e) => {
4834
+ const on = e.target.checked && await enableNotifications();
4835
+ e.target.checked = on;
4836
+ ui.notify = on;
4837
+ savePrefs();
4838
+ };
4270
4839
  $("studio").hidden = !EDITABLE;
4271
4840
  $("studio").onclick = openStudio;
4272
4841
  var autoTimer = null;
@@ -4315,10 +4884,15 @@
4315
4884
  if (ui.open) {
4316
4885
  e.preventDefault();
4317
4886
  closeItem();
4887
+ return;
4888
+ }
4889
+ if (ui.selecting) {
4890
+ e.preventDefault();
4891
+ setSelecting(false);
4318
4892
  }
4319
4893
  return;
4320
4894
  }
4321
- if (ui.open && !typing) {
4895
+ if (ui.open && !typing && !$("dialog-host").childNodes.length) {
4322
4896
  if (e.key === "ArrowLeft") {
4323
4897
  e.preventDefault();
4324
4898
  step(-1);
@@ -4326,6 +4900,14 @@
4326
4900
  e.preventDefault();
4327
4901
  step(1);
4328
4902
  }
4903
+ const frames = drawerFrames();
4904
+ if (frames && (e.key === "," || e.key === ".")) {
4905
+ e.preventDefault();
4906
+ frames.step(e.key === "," ? -1 : 1);
4907
+ } else if (frames && e.key === " " && !/^(BUTTON|A)$/.test(document.activeElement?.tagName || "")) {
4908
+ e.preventDefault();
4909
+ frames.toggle();
4910
+ }
4329
4911
  }
4330
4912
  });
4331
4913
  document.addEventListener("visibilitychange", () => {