pixelkiln 0.80.0 → 0.82.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.
@@ -84,7 +84,7 @@ section.project { border-bottom:1px solid var(--line-strong); background:var(--p
84
84
  .card:hover { border-color:var(--line-strong); }
85
85
  .card.active { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); }
86
86
  .card.ghost { border-style:dashed; background:transparent; }
87
- .cell { height:132px; display:grid; place-items:center; background-color:var(--stage-bg);
87
+ .cell { height:132px; flex:none; display:grid; place-items:center; background-color:var(--stage-bg);
88
88
  background-image:var(--stage-pattern); background-size:12px 12px; background-position:0 0,6px 6px;
89
89
  overflow:hidden; position:relative; padding:8px; }
90
90
  .card.ghost .cell { background-image:none; background-color:var(--panel-deep); color:var(--dim); font-size:12px; }
@@ -484,3 +484,57 @@ td.col-on { background:rgba(255,107,53,.08); }
484
484
  .dtabs .tablist button.on { color:var(--accent-soft); border-bottom-color:var(--accent); }
485
485
  .dtabs .tabpanel[hidden] { display:none; }
486
486
  .dtabs .tabpanel > section.meta:first-child { border-top:0; }
487
+
488
+ /* ---- View menu, card sizes, tag chips, shortcut sheet ---- */
489
+ .bar { flex-wrap:nowrap; }
490
+ .bar > button, .bar > .editing { white-space:nowrap; flex:none; }
491
+ .bar .search { flex:0 1 360px; min-width:180px; }
492
+ .menu { position:relative; }
493
+ .menu > summary { list-style:none; cursor:pointer; padding:6px 11px; border:1px solid var(--line); white-space:nowrap; }
494
+ .menu > summary::-webkit-details-marker { display:none; }
495
+ .menu > summary::after { content:' ▾'; color:var(--dim); font-size:11px; }
496
+ .menu[open] > summary { border-color:var(--accent); color:var(--accent-soft); }
497
+ .menu-panel { position:absolute; right:0; top:calc(100% + 6px); z-index:15; width:300px; display:grid; gap:10px;
498
+ padding:12px; background:var(--panel); border:1px solid var(--line-strong); box-shadow:0 10px 30px rgba(0,0,0,.45); }
499
+ .menu-row { display:grid; grid-template-columns:78px 1fr; align-items:center; gap:10px; font-size:12.5px; }
500
+ .menu-row > span:first-child { color:var(--dim); font:600 11.5px/1 var(--mono); }
501
+ .menu-row.check { display:flex; gap:8px; color:var(--text); }
502
+ .menu-row[hidden] { display:none; }
503
+ .menu-row select { width:100%; }
504
+ .seg { display:inline-flex; }
505
+ .seg button { padding:4px 12px; font:600 12px/1 var(--mono); }
506
+ .seg button + button { border-left:0; }
507
+ .seg button.on { background:var(--panel-deep); color:var(--accent-soft); border-color:var(--accent); }
508
+ #keys { width:32px; padding:6px 0; font:700 13px/1 var(--mono); }
509
+ body[data-size="s"] .grid { grid-template-columns:repeat(auto-fill, minmax(132px, 1fr)); gap:8px; }
510
+ body[data-size="s"] .cell { height:96px; }
511
+ /* A card not yet drawn holds this size (content-visibility), so it has to match the card size. */
512
+ body[data-size="s"] .card { contain-intrinsic-size:auto 132px auto 160px; }
513
+ body[data-size="l"] .card { contain-intrinsic-size:auto 260px auto 275px; }
514
+ body[data-size="s"] .cbody { padding:6px 8px; }
515
+ body[data-size="l"] .grid { grid-template-columns:repeat(auto-fill, minmax(260px, 1fr)); }
516
+ body[data-size="l"] .cell { height:210px; }
517
+ .chip.tag { font-family:var(--mono); }
518
+ .keys-sheet { background:var(--panel); border:1px solid var(--accent); width:min(620px, calc(100vw - 32px)); max-height:calc(100vh - 64px);
519
+ overflow-y:auto; padding:16px 18px; display:grid; gap:14px; }
520
+ .keys-head { display:flex; align-items:center; justify-content:space-between; }
521
+ .keys-sheet h3 { margin:0; font:650 14px/1.2 var(--mono); }
522
+ .keys-sheet h4 { margin:0 0 6px; font:650 12px/1 var(--mono); color:var(--accent-soft); }
523
+ .keys-sheet dl { display:grid; grid-template-columns:150px 1fr; gap:6px 16px; margin:0; font-size:13px; }
524
+ .keys-sheet dt { white-space:nowrap; }
525
+ .keys-sheet dd { margin:0; color:var(--dim); }
526
+ .keys-sheet kbd { font:600 11.5px/1 var(--mono); border:1px solid var(--line-strong); border-bottom-width:2px; padding:2px 6px; }
527
+ @media (max-width: 900px) { .bar { flex-wrap:wrap; } }
528
+
529
+ /* ---- toast, studio template preview ---- */
530
+ .toast { position:fixed; left:22px; bottom:22px; z-index:40; display:flex; align-items:center; gap:10px; max-width:min(560px, calc(100vw - 44px));
531
+ padding:9px 10px 9px 14px; background:var(--panel); border:1px solid var(--line-strong); border-left:3px solid var(--ok);
532
+ box-shadow:0 10px 30px rgba(0,0,0,.45); font-size:13px; }
533
+ .toast[hidden] { display:none; }
534
+ .toast.bad { border-left-color:var(--bad); }
535
+ .toast button { padding:4px 10px; font-size:12.5px; }
536
+ .toast button.x { border:0; color:var(--dim); padding:4px 6px; }
537
+ .studio-tpl-preview { display:flex; align-items:center; gap:10px; }
538
+ .studio-tpl-preview[hidden] { display:none; }
539
+ .studio-tpl-preview img { width:48px; height:48px; image-rendering:pixelated; object-fit:contain; background-color:var(--stage-bg); background-image:var(--stage-pattern); background-size:8px 8px; border:1px solid var(--line); }
540
+ .studio-draft { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
@@ -47,16 +47,26 @@
47
47
  onion: false,
48
48
  selecting: false,
49
49
  selected: /* @__PURE__ */ new Set(),
50
- drawerTab: "overview"
50
+ drawerTab: "overview",
51
+ tags: /* @__PURE__ */ new Set(),
52
+ size: "m"
51
53
  };
54
+ var CARD_SIZES = {
55
+ s: { box: [112, 80], tile: [48, 36] },
56
+ m: { box: [156, 116], tile: [66, 52] },
57
+ l: { box: [240, 190], tile: [108, 88] }
58
+ };
59
+ var declaredTags = (item) => item.asset && Array.isArray(item.asset.tags) ? item.asset.tags : [];
52
60
  var PREFS_KEY = "pixelkiln.gallery.prefs";
53
- var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion", "drawerTab"];
61
+ var PREF_NAMES = ["playLoops", "backdrop", "notify", "onion", "drawerTab", "size"];
54
62
  function loadPrefs() {
55
63
  try {
56
64
  const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
57
65
  for (const name of PREF_NAMES) if (typeof saved[name] === typeof ui[name]) ui[name] = saved[name];
58
66
  } catch {
59
67
  }
68
+ if (!CARD_SIZES[ui.size]) ui.size = "m";
69
+ document.body.dataset.size = ui.size;
60
70
  applyBackdrop();
61
71
  }
62
72
  function savePrefs() {
@@ -140,7 +150,15 @@
140
150
  return s >= 1 ? Math.floor(s) : s;
141
151
  };
142
152
  var projectOf = (item) => S.snap.workspace ? S.snap.workspace.projects.find((pr) => pr.id === item.project) : S.snap.project;
153
+ var editHooks = { saved: null };
154
+ var manifestShaOf = (snap, project) => snap.workspace ? snap.workspace.projects.find((pr) => pr.id === project)?.manifestSha256 ?? null : snap.project?.manifestSha256 ?? null;
143
155
  async function postEdit(body) {
156
+ const before = manifestShaOf(S.snap, body && body.project);
157
+ const snap = await sendEdit(body);
158
+ editHooks.saved?.(body, manifestShaOf(snap, body && body.project) !== before);
159
+ return snap;
160
+ }
161
+ async function sendEdit(body) {
144
162
  const res = await fetch("/api/edit", {
145
163
  method: "POST",
146
164
  headers: { "Content-Type": "application/json", "X-Pixelkiln-Session": SESSION ?? "" },
@@ -1197,6 +1215,7 @@
1197
1215
  if (ui.providers.size && !ui.providers.has(item.provider)) return false;
1198
1216
  if (ui.generators.size && !ui.generators.has(item.generator)) return false;
1199
1217
  if (ui.projects.size && !ui.projects.has(item.project ?? "")) return false;
1218
+ if (ui.tags.size && !declaredTags(item).some((t) => ui.tags.has(t))) return false;
1200
1219
  if (!q) return true;
1201
1220
  const hay = [
1202
1221
  item.id,
@@ -1304,7 +1323,19 @@
1304
1323
  chips.append(el("span", "sep"));
1305
1324
  for (const [k, v] of [...byGen].sort()) chips.append(chip(k, v, ui.generators, k));
1306
1325
  }
1307
- if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.q) {
1326
+ const byTag = /* @__PURE__ */ new Map();
1327
+ for (const item of S.snap.items) for (const t2 of declaredTags(item)) byTag.set(t2, (byTag.get(t2) || 0) + 1);
1328
+ for (const t2 of ui.tags) if (!byTag.has(t2)) byTag.set(t2, 0);
1329
+ if (byTag.size) {
1330
+ chips.append(el("span", "sep"));
1331
+ for (const [k, v] of [...byTag].sort()) {
1332
+ const c = chip("#" + k, v, ui.tags, k);
1333
+ c.classList.add("tag");
1334
+ c.title = "Assets tagged “" + k + "” in the manifest";
1335
+ chips.append(c);
1336
+ }
1337
+ }
1338
+ if (ui.states.size || ui.providers.size || ui.generators.size || ui.projects.size || ui.tags.size || ui.q) {
1308
1339
  const clear = el("button", "chip", "clear filters");
1309
1340
  clear.type = "button";
1310
1341
  clear.onclick = () => clearFilters();
@@ -1316,6 +1347,7 @@
1316
1347
  ui.providers.clear();
1317
1348
  ui.generators.clear();
1318
1349
  ui.projects.clear();
1350
+ ui.tags.clear();
1319
1351
  ui.q = "";
1320
1352
  $("q").value = "";
1321
1353
  render();
@@ -1337,7 +1369,7 @@
1337
1369
  img2.src = o.url;
1338
1370
  img2.alt = o.role || item.assetId;
1339
1371
  img2.loading = "lazy";
1340
- const s2 = displayScale(w, h, 66, 52);
1372
+ const s2 = displayScale(w, h, ...CARD_SIZES[ui.size].tile);
1341
1373
  if (s2 >= 1) {
1342
1374
  img2.width = w * s2;
1343
1375
  img2.height = h * s2;
@@ -1352,7 +1384,7 @@
1352
1384
  img.src = shown[0].url;
1353
1385
  img.alt = item.assetId;
1354
1386
  img.loading = "lazy";
1355
- const s = displayScale(w, h, 156, 116);
1387
+ const s = displayScale(w, h, ...CARD_SIZES[ui.size].box);
1356
1388
  if (s >= 1) {
1357
1389
  img.width = w * s;
1358
1390
  img.height = h * s;
@@ -4489,6 +4521,7 @@
4489
4521
  ui.states = new Set(list("state"));
4490
4522
  ui.providers = new Set(list("provider"));
4491
4523
  ui.generators = new Set(list("generator"));
4524
+ ui.tags = new Set(list("tag"));
4492
4525
  ui.projects = new Set(list("project"));
4493
4526
  ui.compare = list("compare").slice(0, 4);
4494
4527
  if (["key", "newest", "oldest", "cost", "size"].includes(params.get("sort") ?? "")) ui.sort = params.get("sort");
@@ -4500,6 +4533,7 @@
4500
4533
  if (ui.states.size) params.set("state", [...ui.states].join(","));
4501
4534
  if (ui.providers.size) params.set("provider", [...ui.providers].join(","));
4502
4535
  if (ui.generators.size) params.set("generator", [...ui.generators].join(","));
4536
+ if (ui.tags.size) params.set("tag", [...ui.tags].join(","));
4503
4537
  if (ui.projects.size) params.set("project", [...ui.projects].join(","));
4504
4538
  if (ui.compare.length) params.set("compare", ui.compare.join(","));
4505
4539
  if (ui.sort !== "key") params.set("sort", ui.sort);
@@ -4619,6 +4653,48 @@
4619
4653
  { id: "pro", label: "pro: described in words, sequential (20-40 per direction)" }
4620
4654
  ];
4621
4655
  var templated = (mode) => mode === "skeleton-v3" || mode === "template";
4656
+ var KNOWN_TEMPLATES = [
4657
+ "walk",
4658
+ "walking-8-frames",
4659
+ "running-8-frames",
4660
+ "breathing-idle",
4661
+ "crouched-walking",
4662
+ "attack",
4663
+ "attack-back",
4664
+ "attack-left",
4665
+ "attack-right",
4666
+ "cross-punch",
4667
+ "backflip",
4668
+ "angry",
4669
+ "bark"
4670
+ ];
4671
+ var draftKey = () => "pixelkiln.studio.draft:" + (S.snap.workspace ? S.snap.workspace.path : S.snap.project ? S.snap.project.manifest : "");
4672
+ function readDraft() {
4673
+ try {
4674
+ const d = JSON.parse(localStorage.getItem(draftKey()) || "null");
4675
+ return d && typeof d === "object" ? d : null;
4676
+ } catch {
4677
+ return null;
4678
+ }
4679
+ }
4680
+ function writeDraft(draft) {
4681
+ try {
4682
+ if (draft) localStorage.setItem(draftKey(), JSON.stringify(draft));
4683
+ else localStorage.removeItem(draftKey());
4684
+ } catch {
4685
+ }
4686
+ }
4687
+ function usedTemplates(projectId) {
4688
+ const out = /* @__PURE__ */ new Map();
4689
+ for (const i of S.snap.items) {
4690
+ const t = i.asset && i.asset.animation && i.asset.animation.template;
4691
+ if (!t || i.project !== projectId) continue;
4692
+ const frames = i.outputs.filter((o) => o.url);
4693
+ if (frames.length > 1 && !out.get(t)) out.set(t, i);
4694
+ else if (!out.has(t)) out.set(t, null);
4695
+ }
4696
+ return out;
4697
+ }
4622
4698
  var idPattern = "[^\\/\\\\:]+";
4623
4699
  function openStudio() {
4624
4700
  const host = $("dialog-host");
@@ -4631,10 +4707,12 @@
4631
4707
  const scrim = el("div", "sheet-scrim");
4632
4708
  const panel = el("div", "sheet studio");
4633
4709
  let closed = false;
4710
+ const previewTimers = /* @__PURE__ */ new Set();
4634
4711
  const close = () => {
4635
4712
  closed = true;
4636
4713
  if (priceTimer) clearTimeout(priceTimer);
4637
4714
  priceSeq++;
4715
+ for (const t of previewTimers) clearInterval(t);
4638
4716
  host.textContent = "";
4639
4717
  };
4640
4718
  const bar = el("div", "rbar");
@@ -4708,7 +4786,9 @@
4708
4786
  const loopList = el("div", "studio-loops");
4709
4787
  const addLoop = el("button", "add", "+ Add a loop");
4710
4788
  addLoop.type = "button";
4711
- loopsSection.append(el("h3", null, "Loops"), el("small", "state-dim", "Each loop is drawn once per ticked direction; a direction whose mirror is ticked is flipped from it for free."), loopList, addLoop);
4789
+ const templateList = el("datalist");
4790
+ templateList.id = "studio-templates";
4791
+ loopsSection.append(el("h3", null, "Loops"), el("small", "state-dim", "Each loop is drawn once per ticked direction; a direction whose mirror is ticked is flipped from it for free."), loopList, addLoop, templateList);
4712
4792
  const portrait = el("input");
4713
4793
  portrait.type = "checkbox";
4714
4794
  const portraitSize = el("select");
@@ -4779,7 +4859,54 @@
4779
4859
  function syncLoopMode(row2) {
4780
4860
  const t = templated(row2.mode.value);
4781
4861
  row2.what.placeholder = t ? "template, e.g. walk, breathing-idle, running-8-frames" : 'the motion, e.g. "swings a sword overhead"';
4782
- row2.what.title = t ? "A PixelLab template id" : "What the loop does";
4862
+ row2.what.title = t ? "A PixelLab template id: pick one, or type any id PixelLab has" : "What the loop does";
4863
+ if (t) row2.what.setAttribute("list", templateList.id);
4864
+ else row2.what.removeAttribute("list");
4865
+ syncPreview(row2);
4866
+ }
4867
+ function syncPreview(row2) {
4868
+ const old = row2.preview.__timer;
4869
+ if (old) {
4870
+ clearInterval(old);
4871
+ previewTimers.delete(old);
4872
+ }
4873
+ row2.preview.textContent = "";
4874
+ const name = row2.what.value.trim();
4875
+ const sample = templated(row2.mode.value) && name ? usedTemplates(projectId()).get(name) : null;
4876
+ if (!sample) {
4877
+ row2.preview.hidden = true;
4878
+ return;
4879
+ }
4880
+ const urls = sample.outputs.filter((o) => o.url).map((o) => o.url);
4881
+ const img = el("img");
4882
+ img.src = urls[0];
4883
+ img.alt = name;
4884
+ let i = 0;
4885
+ const timer = setInterval(() => {
4886
+ if (!img.isConnected) {
4887
+ clearInterval(timer);
4888
+ previewTimers.delete(timer);
4889
+ return;
4890
+ }
4891
+ if (!document.hidden) {
4892
+ i = (i + 1) % urls.length;
4893
+ img.src = urls[i];
4894
+ }
4895
+ }, Math.round(1e3 / (sample.fps || 12)));
4896
+ row2.preview.__timer = timer;
4897
+ previewTimers.add(timer);
4898
+ row2.preview.append(img, el("small", "state-dim", name + ", as drawn for " + sample.assetId));
4899
+ row2.preview.hidden = false;
4900
+ }
4901
+ function fillTemplateList() {
4902
+ templateList.textContent = "";
4903
+ const used = usedTemplates(projectId());
4904
+ for (const t of [.../* @__PURE__ */ new Set([...used.keys(), ...KNOWN_TEMPLATES])]) {
4905
+ const o = el("option");
4906
+ o.value = t;
4907
+ if (used.has(t)) o.label = "used in this project";
4908
+ templateList.append(o);
4909
+ }
4783
4910
  }
4784
4911
  function addLoopRow(defaults = {}) {
4785
4912
  const wrap = el("div", "studio-loop");
@@ -4809,11 +4936,15 @@
4809
4936
  }
4810
4937
  const remove = el("button", null, "Remove");
4811
4938
  remove.type = "button";
4812
- const row2 = { id, mode, what, directions: boxes, wrap };
4939
+ const preview = el("div", "studio-tpl-preview");
4940
+ preview.hidden = true;
4941
+ const row2 = { id, mode, what, directions: boxes, wrap, preview };
4942
+ what.addEventListener("input", () => syncPreview(row2));
4813
4943
  remove.onclick = () => {
4814
4944
  loops2.splice(loops2.indexOf(row2), 1);
4815
4945
  wrap.remove();
4816
4946
  schedulePrice();
4947
+ keepDraft();
4817
4948
  };
4818
4949
  mode.onchange = () => {
4819
4950
  syncLoopMode(row2);
@@ -4821,7 +4952,7 @@
4821
4952
  };
4822
4953
  const line = el("div", "row");
4823
4954
  line.append(field("loop id", id), field("mode", mode), field("template or motion", what));
4824
- wrap.append(line, dirs, remove);
4955
+ wrap.append(line, preview, dirs, remove);
4825
4956
  loopList.append(wrap);
4826
4957
  loops2.push(row2);
4827
4958
  syncLoopMode(row2);
@@ -4959,6 +5090,7 @@
4959
5090
  const keys = price ? price.items.filter((i) => ours(i.key)).map((i) => i.key) : [];
4960
5091
  try {
4961
5092
  S.snap = await postEdit(request());
5093
+ writeDraft(null);
4962
5094
  const newId = (pid ? pid + ":" : "") + sid + "/" + base;
4963
5095
  if (generate && keys.length) {
4964
5096
  await postGenerate({ keys, ...pid ? { project: pid } : {} });
@@ -5052,19 +5184,265 @@
5052
5184
  };
5053
5185
  form.addEventListener("input", schedulePrice);
5054
5186
  form.addEventListener("change", schedulePrice);
5187
+ const capture = () => ({
5188
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
5189
+ project: project.value,
5190
+ style: style.value,
5191
+ styleId: styleId.value,
5192
+ engine: engine.value,
5193
+ size: size.value,
5194
+ directions: directions.value,
5195
+ body: body.value,
5196
+ view: view.value,
5197
+ outDir: outDir.value,
5198
+ outDirTouched,
5199
+ baseId: baseId.value,
5200
+ prompt: prompt.value,
5201
+ reference,
5202
+ loops: loops2.map((r) => ({ id: r.id.value, mode: r.mode.value, what: r.what.value, directions: [...r.directions].filter(([, b]) => b.checked).map(([d]) => d) })),
5203
+ portrait: portrait.checked,
5204
+ portraitSize: portraitSize.value
5205
+ });
5206
+ let draftTimer = null;
5207
+ const keepDraft = () => {
5208
+ if (closed) return;
5209
+ if (draftTimer) clearTimeout(draftTimer);
5210
+ draftTimer = setTimeout(() => {
5211
+ if (!closed) writeDraft(baseId.value.trim() || prompt.value.trim() ? capture() : null);
5212
+ }, 300);
5213
+ };
5214
+ form.addEventListener("input", keepDraft);
5215
+ form.addEventListener("change", keepDraft);
5216
+ addLoop.addEventListener("click", keepDraft);
5055
5217
  fillStyles();
5218
+ fillTemplateList();
5056
5219
  syncEngine();
5057
5220
  syncStyle();
5058
- addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5059
- addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5221
+ const draft = readDraft();
5222
+ if (draft && (draft.baseId || draft.prompt)) {
5223
+ const set = (input, value) => {
5224
+ if (typeof value !== "string") return;
5225
+ if (input instanceof HTMLSelectElement && ![...input.options].some((o) => o.value === value)) return;
5226
+ input.value = value;
5227
+ };
5228
+ set(project, draft.project);
5229
+ fillStyles();
5230
+ set(style, draft.style);
5231
+ set(engine, draft.engine);
5232
+ syncEngine();
5233
+ set(styleId, draft.styleId);
5234
+ set(size, draft.size);
5235
+ set(directions, draft.directions);
5236
+ set(body, draft.body);
5237
+ set(view, draft.view);
5238
+ outDirTouched = Boolean(draft.outDirTouched);
5239
+ set(outDir, draft.outDir);
5240
+ set(baseId, draft.baseId);
5241
+ set(prompt, draft.prompt);
5242
+ if (typeof draft.reference === "string") {
5243
+ reference = draft.reference;
5244
+ refField.append(el("small", "state-dim studio-ref-kept", "Using " + draft.reference + ", uploaded with this draft. Choose a file to replace it."));
5245
+ }
5246
+ portrait.checked = Boolean(draft.portrait);
5247
+ set(portraitSize, draft.portraitSize);
5248
+ syncStyle();
5249
+ for (const l of Array.isArray(draft.loops) ? draft.loops : []) addLoopRow({ id: l.id, mode: l.mode, what: l.what, directions: l.directions });
5250
+ const note = el("div", "notice studio-draft");
5251
+ note.append(document.createTextNode("Picked up the draft you left " + (fmtWhen(draft.savedAt) || "earlier") + ". "));
5252
+ const discard = el("button", "linkish", "Start over");
5253
+ discard.type = "button";
5254
+ discard.onclick = () => {
5255
+ writeDraft(null);
5256
+ close();
5257
+ openStudio();
5258
+ };
5259
+ note.append(discard);
5260
+ form.prepend(note);
5261
+ } else {
5262
+ addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5263
+ addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5264
+ }
5060
5265
  updatePrice();
5061
5266
  setTimeout(() => baseId.focus(), 0);
5062
5267
  }
5063
5268
 
5269
+ // src/gallery/client/src/keys.ts
5270
+ var GROUPS = [
5271
+ {
5272
+ title: "Anywhere",
5273
+ keys: [
5274
+ [["/"], "jump to search"],
5275
+ [["?"], "this list"],
5276
+ [["Esc"], "close whatever is open: a sheet, the record, the View menu, or select mode"],
5277
+ [["⌘/ctrl", "Z"], "undo the gallery's last manifest edit (with --edit)"]
5278
+ ]
5279
+ },
5280
+ {
5281
+ title: "On the grid",
5282
+ keys: [
5283
+ [["click"], "open a record"],
5284
+ [["shift", "click"], "add it to a side-by-side comparison (up to four)"],
5285
+ [["ctrl/⌘", "click"], "select it; the bar at the bottom acts on the selection"],
5286
+ [["hover"], "play a loop"]
5287
+ ]
5288
+ },
5289
+ {
5290
+ title: "With a record open",
5291
+ keys: [
5292
+ [["←", "→"], "step through the records in view"],
5293
+ [[",", "."], "step a loop one frame back or on"],
5294
+ [["space"], "play or pause a loop"],
5295
+ [["←", "→"], "switch tabs, once a tab has focus"]
5296
+ ]
5297
+ },
5298
+ {
5299
+ title: "In the family view",
5300
+ keys: [
5301
+ [["drag"], "turn the sprite on the turntable"],
5302
+ [["←", "→"], "turn it a step, once the turntable has focus"]
5303
+ ]
5304
+ }
5305
+ ];
5306
+ function openShortcuts() {
5307
+ const host = $("dialog-host");
5308
+ host.textContent = "";
5309
+ const wrap = el("div", "dialog");
5310
+ wrap.onclick = (e) => {
5311
+ if (e.target === wrap) host.textContent = "";
5312
+ };
5313
+ const box = el("div", "keys-sheet");
5314
+ box.setAttribute("role", "dialog");
5315
+ box.setAttribute("aria-label", "Keyboard shortcuts");
5316
+ const head = el("div", "keys-head");
5317
+ head.append(el("h3", null, "Keyboard shortcuts"));
5318
+ const close = el("button", null, "Close");
5319
+ close.type = "button";
5320
+ close.onclick = () => {
5321
+ host.textContent = "";
5322
+ };
5323
+ head.append(close);
5324
+ box.append(head);
5325
+ for (const group of GROUPS) {
5326
+ const section2 = el("section");
5327
+ section2.append(el("h4", null, group.title));
5328
+ const dl = el("dl");
5329
+ for (const [keys, what] of group.keys) {
5330
+ const dt = el("dt");
5331
+ keys.forEach((k, i) => {
5332
+ if (i) dt.append(document.createTextNode(k === "click" ? "+" : " "));
5333
+ dt.append(el("kbd", null, k));
5334
+ });
5335
+ dl.append(dt, el("dd", null, what));
5336
+ }
5337
+ section2.append(dl);
5338
+ box.append(section2);
5339
+ }
5340
+ wrap.append(box);
5341
+ host.append(wrap);
5342
+ close.focus();
5343
+ }
5344
+
5345
+ // src/gallery/client/src/undo.ts
5346
+ var lastProject = null;
5347
+ var hideTimer = null;
5348
+ function describe(body) {
5349
+ switch (body.action) {
5350
+ case "batch":
5351
+ return body.edits.length + " changes to the manifest";
5352
+ case "add-style":
5353
+ return "added style " + body.styleId;
5354
+ case "patch-style":
5355
+ return "changed style " + body.styleId;
5356
+ case "add-asset":
5357
+ return "added " + body.assetId;
5358
+ case "patch-asset":
5359
+ return "changed " + body.assetId;
5360
+ case "set-source":
5361
+ case "clear-source":
5362
+ return "changed the source of " + body.assetId;
5363
+ case "create-skeleton-animation":
5364
+ return "added " + body.assetId;
5365
+ case "start-edit":
5366
+ return "started a hand edit of " + body.assetId;
5367
+ case "save-edit":
5368
+ return "saved a hand edit of " + body.assetId;
5369
+ case "detach-edit":
5370
+ return "detached the hand edit of " + body.assetId;
5371
+ default:
5372
+ return "the manifest changed";
5373
+ }
5374
+ }
5375
+ function toast(text, canUndo, tone = "") {
5376
+ const host = $("toast");
5377
+ host.textContent = "";
5378
+ host.className = "toast" + (tone ? " " + tone : "");
5379
+ host.append(el("span", null, text));
5380
+ if (canUndo) {
5381
+ const b = el("button", null, "Undo");
5382
+ b.type = "button";
5383
+ b.title = "Put the manifest back as it was (⌘Z / ctrl+Z)";
5384
+ b.onclick = () => void undoLast();
5385
+ host.append(b);
5386
+ }
5387
+ const x = el("button", "x", "×");
5388
+ x.type = "button";
5389
+ x.setAttribute("aria-label", "Dismiss");
5390
+ x.onclick = () => {
5391
+ host.hidden = true;
5392
+ };
5393
+ host.append(x);
5394
+ host.hidden = false;
5395
+ if (hideTimer) clearTimeout(hideTimer);
5396
+ hideTimer = setTimeout(() => {
5397
+ host.hidden = true;
5398
+ }, 12e3);
5399
+ }
5400
+ function installUndo() {
5401
+ editHooks.saved = (body, changed) => {
5402
+ if (!body || body.action === "undo-edit" || !changed) return;
5403
+ lastProject = body.project ?? null;
5404
+ toast("Saved: " + describe(body) + ".", true);
5405
+ };
5406
+ }
5407
+ async function undoLast() {
5408
+ if (!EDITABLE) return;
5409
+ const expected = manifestShaOf(S.snap, lastProject);
5410
+ if (!expected) return;
5411
+ const body = { action: "undo-edit", expectedSha256: expected };
5412
+ if (lastProject) body.project = lastProject;
5413
+ try {
5414
+ S.snap = await postEdit(body);
5415
+ render();
5416
+ toast("Undone. The manifest is back as it was before that edit.", true);
5417
+ } catch (err) {
5418
+ toast(/nothing to undo/.test(err.message) ? "Nothing left to undo." : "Could not undo: " + err.message, false, "bad");
5419
+ }
5420
+ }
5421
+
5064
5422
  // src/gallery/client/src/main.ts
5065
5423
  loadPrefs();
5424
+ installUndo();
5066
5425
  $("refresh").onclick = refresh;
5067
5426
  $("select").onclick = () => setSelecting(!ui.selecting);
5427
+ $("keys").onclick = openShortcuts;
5428
+ var syncSize = () => {
5429
+ for (const b of $("size").querySelectorAll("button")) b.classList.toggle("on", b.dataset.size === ui.size);
5430
+ };
5431
+ for (const b of $("size").querySelectorAll("button")) {
5432
+ b.onclick = () => {
5433
+ if (!CARD_SIZES[b.dataset.size]) return;
5434
+ ui.size = b.dataset.size;
5435
+ document.body.dataset.size = ui.size;
5436
+ savePrefs();
5437
+ syncSize();
5438
+ render();
5439
+ };
5440
+ }
5441
+ syncSize();
5442
+ document.addEventListener("click", (e) => {
5443
+ const menu = $("view-menu");
5444
+ if (menu.open && !menu.contains(e.target)) menu.open = false;
5445
+ });
5068
5446
  $("playloops").checked = ui.playLoops;
5069
5447
  $("playloops").onchange = (e) => {
5070
5448
  setPlayLoops(e.target.checked);
@@ -5109,7 +5487,23 @@
5109
5487
  $("q").focus();
5110
5488
  return;
5111
5489
  }
5490
+ if (e.key === "?" && !typing) {
5491
+ e.preventDefault();
5492
+ openShortcuts();
5493
+ return;
5494
+ }
5495
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z" && !typing && EDITABLE && !$("dialog-host").childNodes.length) {
5496
+ e.preventDefault();
5497
+ void undoLast();
5498
+ return;
5499
+ }
5112
5500
  if (e.key === "Escape") {
5501
+ if ($("view-menu").open) {
5502
+ e.preventDefault();
5503
+ $("view-menu").open = false;
5504
+ $("view-menu").querySelector("summary").focus();
5505
+ return;
5506
+ }
5113
5507
  if (typing && document.activeElement?.id === "q") {
5114
5508
  document.activeElement.blur();
5115
5509
  return;