pixelkiln 0.81.0 → 0.83.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.
@@ -525,3 +525,16 @@ body[data-size="l"] .cell { height:210px; }
525
525
  .keys-sheet dd { margin:0; color:var(--dim); }
526
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
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; }
@@ -150,7 +150,15 @@
150
150
  return s >= 1 ? Math.floor(s) : s;
151
151
  };
152
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;
153
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) {
154
162
  const res = await fetch("/api/edit", {
155
163
  method: "POST",
156
164
  headers: { "Content-Type": "application/json", "X-Pixelkiln-Session": SESSION ?? "" },
@@ -879,6 +887,23 @@
879
887
  setTimeout(() => id.focus(), 0);
880
888
  return form;
881
889
  }
890
+ function revisionModesFor(item) {
891
+ if (item.provider !== "pixellab") return [["image-to-image", "edit with a text instruction"]];
892
+ if (item.outputs.length > 1) {
893
+ return [
894
+ ["edit-animation", "one text edit across every frame"],
895
+ ["reduce-colors", "fewer colours, across every frame"],
896
+ ["correct-pixelart", "clean up the pixel grid, across every frame"]
897
+ ];
898
+ }
899
+ return [
900
+ ["image-to-image", "edit with a text instruction"],
901
+ ["animate", "animate it from a text description"],
902
+ ["animate-pixminimax", "animate it, holding the facing direction"],
903
+ ["reduce-colors", "fewer colours, optional dithering"],
904
+ ["correct-pixelart", "clean up the pixel grid"]
905
+ ];
906
+ }
882
907
  function newRevisionForm(item) {
883
908
  const pr = projectOf(item);
884
909
  const form = el("form", "edit");
@@ -889,18 +914,83 @@
889
914
  id.required = true;
890
915
  id.autocomplete = "off";
891
916
  id.pattern = "[^\\/\\\\]+";
917
+ const modes = revisionModesFor(item);
918
+ const mode = el("select");
919
+ for (const [value, label] of modes) mode.append(new Option(value + ": " + label, value));
892
920
  const prompt = el("textarea");
893
- prompt.placeholder = 'Edit instruction, e.g. "add snow on the roof".';
894
- prompt.required = true;
895
921
  const strength = el("input");
896
922
  strength.type = "number";
897
923
  strength.min = "0";
898
924
  strength.max = "1";
899
925
  strength.step = "0.05";
900
926
  strength.placeholder = "provider default";
927
+ const frames = el("input");
928
+ frames.type = "number";
929
+ frames.min = "4";
930
+ frames.max = "40";
931
+ frames.step = "2";
932
+ frames.placeholder = "provider default";
933
+ const fps = el("input");
934
+ fps.type = "number";
935
+ fps.min = "1";
936
+ fps.max = "60";
937
+ fps.step = "1";
938
+ fps.placeholder = "playback rate";
939
+ const enhance = el("input");
940
+ enhance.type = "checkbox";
941
+ const enhanceField = el("label", "field check");
942
+ enhanceField.append(enhance, el("span", null, "let PixelLab expand the motion description first"));
943
+ const direction = el("select");
944
+ for (const d of ["south", "south-east", "east", "north-east", "north", "north-west", "west", "south-west"]) direction.append(new Option(d, d));
945
+ const numColors = el("input");
946
+ numColors.type = "number";
947
+ numColors.min = "2";
948
+ numColors.max = "256";
949
+ numColors.step = "1";
950
+ numColors.placeholder = "PixelLab default";
951
+ const dithering = el("select");
952
+ for (const d of ["", "none", "2x2", "4x4", "8x8"]) dithering.append(new Option(d || "default", d));
901
953
  const row1 = el("div", "row");
902
- row1.append(field("new asset id", id), field("strength", strength, "PixelLab rejects an explicit strength for image-to-image; leave this blank there."));
903
- form.append(row1, field("edit instruction", prompt, "The style still adds its prefix and suffix."));
954
+ row1.append(field("new asset id", id), field("mode", mode));
955
+ const promptField = field("instruction", prompt);
956
+ const strengthField = field("strength", strength, "PixelLab rejects an explicit strength for image-to-image; leave this blank there. Optional for correct-pixelart.");
957
+ const framesField = field("frames", frames, "Even, 4 to 40; animate allows up to 16.");
958
+ const fpsField = field("fps", fps, "Recorded with the frames; PixelLab does not store one.");
959
+ const directionField = field("facing", direction, "Holds the sprite's facing while the motion is expanded.");
960
+ const colorsField = field("colours", numColors);
961
+ const ditherField = field("dithering", dithering);
962
+ const grids = el("div", "row");
963
+ grids.append(framesField, fpsField);
964
+ const cleanup = el("div", "row");
965
+ cleanup.append(colorsField, ditherField);
966
+ form.append(row1, promptField, strengthField, grids, directionField, enhanceField, cleanup);
967
+ const hints = {
968
+ "image-to-image": ["edit instruction", "add snow on the roof", "The style still adds its prefix and suffix."],
969
+ animate: ["motion", "the chest wobbling gently", "Describes the movement; the parent image is the first frame."],
970
+ "animate-pixminimax": ["motion", "the chest wobbling gently", "Describes the movement; the parent image is the first frame."],
971
+ "edit-animation": ["edit instruction", "add a red cape", "Applied across every frame in one call, so the change stays consistent."],
972
+ "reduce-colors": ["note", "optional", "Cleanup only: the text is kept as a label, not sent."],
973
+ "correct-pixelart": ["note", "optional", "Cleanup only: the text is kept as a label, not sent."]
974
+ };
975
+ const sync = () => {
976
+ const m = mode.value;
977
+ const [label, placeholder, hint] = hints[m];
978
+ promptField.querySelector("span").textContent = label;
979
+ promptField.querySelector("small")?.remove();
980
+ promptField.append(el("small", null, hint));
981
+ prompt.placeholder = placeholder;
982
+ prompt.required = m !== "reduce-colors" && m !== "correct-pixelart";
983
+ strengthField.hidden = m !== "image-to-image" && m !== "correct-pixelart";
984
+ grids.hidden = !(m === "animate" || m === "animate-pixminimax" || m === "edit-animation");
985
+ framesField.hidden = !(m === "animate" || m === "animate-pixminimax");
986
+ fpsField.hidden = grids.hidden;
987
+ directionField.hidden = !(m === "animate-pixminimax" && enhance.checked);
988
+ enhanceField.hidden = !(m === "animate" || m === "animate-pixminimax");
989
+ cleanup.hidden = m !== "reduce-colors";
990
+ };
991
+ mode.onchange = sync;
992
+ enhance.onchange = sync;
993
+ sync();
904
994
  const actions = el("div", "actions");
905
995
  const save = el("button", "primary", "Create revision");
906
996
  save.type = "submit";
@@ -918,9 +1008,19 @@
918
1008
  save.disabled = true;
919
1009
  msg.className = "msg";
920
1010
  msg.textContent = "saving…";
921
- const revision = { mode: "image-to-image", from: item.assetId };
922
- if (strength.value.trim() !== "") revision.strength = Number(strength.value);
923
- const asset = { prompt: prompt.value, styles: [item.styleId], revision };
1011
+ const m = mode.value;
1012
+ const revision = { mode: m, from: item.assetId };
1013
+ if ((m === "image-to-image" || m === "correct-pixelart") && strength.value.trim() !== "") revision.strength = Number(strength.value);
1014
+ if ((m === "animate" || m === "animate-pixminimax") && frames.value.trim() !== "") revision.frames = Number(frames.value);
1015
+ if ((m === "animate" || m === "animate-pixminimax" || m === "edit-animation") && fps.value.trim() !== "") revision.fps = Number(fps.value);
1016
+ if ((m === "animate" || m === "animate-pixminimax") && enhance.checked) revision.enhancePrompt = true;
1017
+ if (m === "animate-pixminimax" && enhance.checked) revision.direction = direction.value;
1018
+ if (m === "reduce-colors") {
1019
+ if (numColors.value.trim() !== "") revision.numColors = Number(numColors.value);
1020
+ if (dithering.value) revision.dithering = dithering.value;
1021
+ }
1022
+ const text = prompt.value.trim() || (m === "reduce-colors" ? "reduce colours" : m === "correct-pixelart" ? "correct pixel art" : "");
1023
+ const asset = { prompt: text, styles: [item.styleId], revision };
924
1024
  try {
925
1025
  const body = { action: "add-asset", assetId: id.value.trim(), expectedSha256: pr.manifestSha256, asset };
926
1026
  if (item.project) body.project = item.project;
@@ -4365,6 +4465,12 @@
4365
4465
  if (item.revision.maskSha256) row(dl, "mask sha256", item.revision.maskSha256.slice(0, 16) + "…", { mono: true, copy: item.revision.maskSha256 });
4366
4466
  if (item.revision.strength !== void 0) row(dl, "strength", item.revision.strength);
4367
4467
  }
4468
+ const declared = item.asset && item.asset.revision;
4469
+ if (declared) {
4470
+ for (const [label, key] of [["facing", "direction"], ["looks like", "description"], ["body template", "skeletonTemplate"], ["frames", "frames"], ["fps", "fps"], ["colours", "numColors"], ["dithering", "dithering"]]) {
4471
+ if (declared[key] !== void 0) row(dl, label, declared[key]);
4472
+ }
4473
+ }
4368
4474
  if (item.skeleton) {
4369
4475
  row(dl, "keypoints", item.skeleton.keypointsFile, { mono: true });
4370
4476
  if (item.skeleton.set) {
@@ -4645,6 +4751,48 @@
4645
4751
  { id: "pro", label: "pro: described in words, sequential (20-40 per direction)" }
4646
4752
  ];
4647
4753
  var templated = (mode) => mode === "skeleton-v3" || mode === "template";
4754
+ var KNOWN_TEMPLATES = [
4755
+ "walk",
4756
+ "walking-8-frames",
4757
+ "running-8-frames",
4758
+ "breathing-idle",
4759
+ "crouched-walking",
4760
+ "attack",
4761
+ "attack-back",
4762
+ "attack-left",
4763
+ "attack-right",
4764
+ "cross-punch",
4765
+ "backflip",
4766
+ "angry",
4767
+ "bark"
4768
+ ];
4769
+ var draftKey = () => "pixelkiln.studio.draft:" + (S.snap.workspace ? S.snap.workspace.path : S.snap.project ? S.snap.project.manifest : "");
4770
+ function readDraft() {
4771
+ try {
4772
+ const d = JSON.parse(localStorage.getItem(draftKey()) || "null");
4773
+ return d && typeof d === "object" ? d : null;
4774
+ } catch {
4775
+ return null;
4776
+ }
4777
+ }
4778
+ function writeDraft(draft) {
4779
+ try {
4780
+ if (draft) localStorage.setItem(draftKey(), JSON.stringify(draft));
4781
+ else localStorage.removeItem(draftKey());
4782
+ } catch {
4783
+ }
4784
+ }
4785
+ function usedTemplates(projectId) {
4786
+ const out = /* @__PURE__ */ new Map();
4787
+ for (const i of S.snap.items) {
4788
+ const t = i.asset && i.asset.animation && i.asset.animation.template;
4789
+ if (!t || i.project !== projectId) continue;
4790
+ const frames = i.outputs.filter((o) => o.url);
4791
+ if (frames.length > 1 && !out.get(t)) out.set(t, i);
4792
+ else if (!out.has(t)) out.set(t, null);
4793
+ }
4794
+ return out;
4795
+ }
4648
4796
  var idPattern = "[^\\/\\\\:]+";
4649
4797
  function openStudio() {
4650
4798
  const host = $("dialog-host");
@@ -4657,10 +4805,12 @@
4657
4805
  const scrim = el("div", "sheet-scrim");
4658
4806
  const panel = el("div", "sheet studio");
4659
4807
  let closed = false;
4808
+ const previewTimers = /* @__PURE__ */ new Set();
4660
4809
  const close = () => {
4661
4810
  closed = true;
4662
4811
  if (priceTimer) clearTimeout(priceTimer);
4663
4812
  priceSeq++;
4813
+ for (const t of previewTimers) clearInterval(t);
4664
4814
  host.textContent = "";
4665
4815
  };
4666
4816
  const bar = el("div", "rbar");
@@ -4734,7 +4884,9 @@
4734
4884
  const loopList = el("div", "studio-loops");
4735
4885
  const addLoop = el("button", "add", "+ Add a loop");
4736
4886
  addLoop.type = "button";
4737
- 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);
4887
+ const templateList = el("datalist");
4888
+ templateList.id = "studio-templates";
4889
+ 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);
4738
4890
  const portrait = el("input");
4739
4891
  portrait.type = "checkbox";
4740
4892
  const portraitSize = el("select");
@@ -4805,7 +4957,54 @@
4805
4957
  function syncLoopMode(row2) {
4806
4958
  const t = templated(row2.mode.value);
4807
4959
  row2.what.placeholder = t ? "template, e.g. walk, breathing-idle, running-8-frames" : 'the motion, e.g. "swings a sword overhead"';
4808
- row2.what.title = t ? "A PixelLab template id" : "What the loop does";
4960
+ row2.what.title = t ? "A PixelLab template id: pick one, or type any id PixelLab has" : "What the loop does";
4961
+ if (t) row2.what.setAttribute("list", templateList.id);
4962
+ else row2.what.removeAttribute("list");
4963
+ syncPreview(row2);
4964
+ }
4965
+ function syncPreview(row2) {
4966
+ const old = row2.preview.__timer;
4967
+ if (old) {
4968
+ clearInterval(old);
4969
+ previewTimers.delete(old);
4970
+ }
4971
+ row2.preview.textContent = "";
4972
+ const name = row2.what.value.trim();
4973
+ const sample = templated(row2.mode.value) && name ? usedTemplates(projectId()).get(name) : null;
4974
+ if (!sample) {
4975
+ row2.preview.hidden = true;
4976
+ return;
4977
+ }
4978
+ const urls = sample.outputs.filter((o) => o.url).map((o) => o.url);
4979
+ const img = el("img");
4980
+ img.src = urls[0];
4981
+ img.alt = name;
4982
+ let i = 0;
4983
+ const timer = setInterval(() => {
4984
+ if (!img.isConnected) {
4985
+ clearInterval(timer);
4986
+ previewTimers.delete(timer);
4987
+ return;
4988
+ }
4989
+ if (!document.hidden) {
4990
+ i = (i + 1) % urls.length;
4991
+ img.src = urls[i];
4992
+ }
4993
+ }, Math.round(1e3 / (sample.fps || 12)));
4994
+ row2.preview.__timer = timer;
4995
+ previewTimers.add(timer);
4996
+ row2.preview.append(img, el("small", "state-dim", name + ", as drawn for " + sample.assetId));
4997
+ row2.preview.hidden = false;
4998
+ }
4999
+ function fillTemplateList() {
5000
+ templateList.textContent = "";
5001
+ const used = usedTemplates(projectId());
5002
+ for (const t of [.../* @__PURE__ */ new Set([...used.keys(), ...KNOWN_TEMPLATES])]) {
5003
+ const o = el("option");
5004
+ o.value = t;
5005
+ if (used.has(t)) o.label = "used in this project";
5006
+ templateList.append(o);
5007
+ }
4809
5008
  }
4810
5009
  function addLoopRow(defaults = {}) {
4811
5010
  const wrap = el("div", "studio-loop");
@@ -4835,11 +5034,15 @@
4835
5034
  }
4836
5035
  const remove = el("button", null, "Remove");
4837
5036
  remove.type = "button";
4838
- const row2 = { id, mode, what, directions: boxes, wrap };
5037
+ const preview = el("div", "studio-tpl-preview");
5038
+ preview.hidden = true;
5039
+ const row2 = { id, mode, what, directions: boxes, wrap, preview };
5040
+ what.addEventListener("input", () => syncPreview(row2));
4839
5041
  remove.onclick = () => {
4840
5042
  loops2.splice(loops2.indexOf(row2), 1);
4841
5043
  wrap.remove();
4842
5044
  schedulePrice();
5045
+ keepDraft();
4843
5046
  };
4844
5047
  mode.onchange = () => {
4845
5048
  syncLoopMode(row2);
@@ -4847,7 +5050,7 @@
4847
5050
  };
4848
5051
  const line = el("div", "row");
4849
5052
  line.append(field("loop id", id), field("mode", mode), field("template or motion", what));
4850
- wrap.append(line, dirs, remove);
5053
+ wrap.append(line, preview, dirs, remove);
4851
5054
  loopList.append(wrap);
4852
5055
  loops2.push(row2);
4853
5056
  syncLoopMode(row2);
@@ -4985,6 +5188,7 @@
4985
5188
  const keys = price ? price.items.filter((i) => ours(i.key)).map((i) => i.key) : [];
4986
5189
  try {
4987
5190
  S.snap = await postEdit(request());
5191
+ writeDraft(null);
4988
5192
  const newId = (pid ? pid + ":" : "") + sid + "/" + base;
4989
5193
  if (generate && keys.length) {
4990
5194
  await postGenerate({ keys, ...pid ? { project: pid } : {} });
@@ -5078,11 +5282,84 @@
5078
5282
  };
5079
5283
  form.addEventListener("input", schedulePrice);
5080
5284
  form.addEventListener("change", schedulePrice);
5285
+ const capture = () => ({
5286
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
5287
+ project: project.value,
5288
+ style: style.value,
5289
+ styleId: styleId.value,
5290
+ engine: engine.value,
5291
+ size: size.value,
5292
+ directions: directions.value,
5293
+ body: body.value,
5294
+ view: view.value,
5295
+ outDir: outDir.value,
5296
+ outDirTouched,
5297
+ baseId: baseId.value,
5298
+ prompt: prompt.value,
5299
+ reference,
5300
+ 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) })),
5301
+ portrait: portrait.checked,
5302
+ portraitSize: portraitSize.value
5303
+ });
5304
+ let draftTimer = null;
5305
+ const keepDraft = () => {
5306
+ if (closed) return;
5307
+ if (draftTimer) clearTimeout(draftTimer);
5308
+ draftTimer = setTimeout(() => {
5309
+ if (!closed) writeDraft(baseId.value.trim() || prompt.value.trim() ? capture() : null);
5310
+ }, 300);
5311
+ };
5312
+ form.addEventListener("input", keepDraft);
5313
+ form.addEventListener("change", keepDraft);
5314
+ addLoop.addEventListener("click", keepDraft);
5081
5315
  fillStyles();
5316
+ fillTemplateList();
5082
5317
  syncEngine();
5083
5318
  syncStyle();
5084
- addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5085
- addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5319
+ const draft = readDraft();
5320
+ if (draft && (draft.baseId || draft.prompt)) {
5321
+ const set = (input, value) => {
5322
+ if (typeof value !== "string") return;
5323
+ if (input instanceof HTMLSelectElement && ![...input.options].some((o) => o.value === value)) return;
5324
+ input.value = value;
5325
+ };
5326
+ set(project, draft.project);
5327
+ fillStyles();
5328
+ set(style, draft.style);
5329
+ set(engine, draft.engine);
5330
+ syncEngine();
5331
+ set(styleId, draft.styleId);
5332
+ set(size, draft.size);
5333
+ set(directions, draft.directions);
5334
+ set(body, draft.body);
5335
+ set(view, draft.view);
5336
+ outDirTouched = Boolean(draft.outDirTouched);
5337
+ set(outDir, draft.outDir);
5338
+ set(baseId, draft.baseId);
5339
+ set(prompt, draft.prompt);
5340
+ if (typeof draft.reference === "string") {
5341
+ reference = draft.reference;
5342
+ refField.append(el("small", "state-dim studio-ref-kept", "Using " + draft.reference + ", uploaded with this draft. Choose a file to replace it."));
5343
+ }
5344
+ portrait.checked = Boolean(draft.portrait);
5345
+ set(portraitSize, draft.portraitSize);
5346
+ syncStyle();
5347
+ for (const l of Array.isArray(draft.loops) ? draft.loops : []) addLoopRow({ id: l.id, mode: l.mode, what: l.what, directions: l.directions });
5348
+ const note = el("div", "notice studio-draft");
5349
+ note.append(document.createTextNode("Picked up the draft you left " + (fmtWhen(draft.savedAt) || "earlier") + ". "));
5350
+ const discard = el("button", "linkish", "Start over");
5351
+ discard.type = "button";
5352
+ discard.onclick = () => {
5353
+ writeDraft(null);
5354
+ close();
5355
+ openStudio();
5356
+ };
5357
+ note.append(discard);
5358
+ form.prepend(note);
5359
+ } else {
5360
+ addLoopRow({ id: "walk", mode: "skeleton-v3", what: "walk", directions: ["south", "west", "north"] });
5361
+ addLoopRow({ id: "idle", mode: "skeleton-v3", what: "breathing-idle", directions: ["south"] });
5362
+ }
5086
5363
  updatePrice();
5087
5364
  setTimeout(() => baseId.focus(), 0);
5088
5365
  }
@@ -5094,7 +5371,8 @@
5094
5371
  keys: [
5095
5372
  [["/"], "jump to search"],
5096
5373
  [["?"], "this list"],
5097
- [["Esc"], "close whatever is open: a sheet, the record, the View menu, or select mode"]
5374
+ [["Esc"], "close whatever is open: a sheet, the record, the View menu, or select mode"],
5375
+ [["⌘/ctrl", "Z"], "undo the gallery's last manifest edit (with --edit)"]
5098
5376
  ]
5099
5377
  },
5100
5378
  {
@@ -5162,8 +5440,86 @@
5162
5440
  close.focus();
5163
5441
  }
5164
5442
 
5443
+ // src/gallery/client/src/undo.ts
5444
+ var lastProject = null;
5445
+ var hideTimer = null;
5446
+ function describe(body) {
5447
+ switch (body.action) {
5448
+ case "batch":
5449
+ return body.edits.length + " changes to the manifest";
5450
+ case "add-style":
5451
+ return "added style " + body.styleId;
5452
+ case "patch-style":
5453
+ return "changed style " + body.styleId;
5454
+ case "add-asset":
5455
+ return "added " + body.assetId;
5456
+ case "patch-asset":
5457
+ return "changed " + body.assetId;
5458
+ case "set-source":
5459
+ case "clear-source":
5460
+ return "changed the source of " + body.assetId;
5461
+ case "create-skeleton-animation":
5462
+ return "added " + body.assetId;
5463
+ case "start-edit":
5464
+ return "started a hand edit of " + body.assetId;
5465
+ case "save-edit":
5466
+ return "saved a hand edit of " + body.assetId;
5467
+ case "detach-edit":
5468
+ return "detached the hand edit of " + body.assetId;
5469
+ default:
5470
+ return "the manifest changed";
5471
+ }
5472
+ }
5473
+ function toast(text, canUndo, tone = "") {
5474
+ const host = $("toast");
5475
+ host.textContent = "";
5476
+ host.className = "toast" + (tone ? " " + tone : "");
5477
+ host.append(el("span", null, text));
5478
+ if (canUndo) {
5479
+ const b = el("button", null, "Undo");
5480
+ b.type = "button";
5481
+ b.title = "Put the manifest back as it was (⌘Z / ctrl+Z)";
5482
+ b.onclick = () => void undoLast();
5483
+ host.append(b);
5484
+ }
5485
+ const x = el("button", "x", "×");
5486
+ x.type = "button";
5487
+ x.setAttribute("aria-label", "Dismiss");
5488
+ x.onclick = () => {
5489
+ host.hidden = true;
5490
+ };
5491
+ host.append(x);
5492
+ host.hidden = false;
5493
+ if (hideTimer) clearTimeout(hideTimer);
5494
+ hideTimer = setTimeout(() => {
5495
+ host.hidden = true;
5496
+ }, 12e3);
5497
+ }
5498
+ function installUndo() {
5499
+ editHooks.saved = (body, changed) => {
5500
+ if (!body || body.action === "undo-edit" || !changed) return;
5501
+ lastProject = body.project ?? null;
5502
+ toast("Saved: " + describe(body) + ".", true);
5503
+ };
5504
+ }
5505
+ async function undoLast() {
5506
+ if (!EDITABLE) return;
5507
+ const expected = manifestShaOf(S.snap, lastProject);
5508
+ if (!expected) return;
5509
+ const body = { action: "undo-edit", expectedSha256: expected };
5510
+ if (lastProject) body.project = lastProject;
5511
+ try {
5512
+ S.snap = await postEdit(body);
5513
+ render();
5514
+ toast("Undone. The manifest is back as it was before that edit.", true);
5515
+ } catch (err) {
5516
+ toast(/nothing to undo/.test(err.message) ? "Nothing left to undo." : "Could not undo: " + err.message, false, "bad");
5517
+ }
5518
+ }
5519
+
5165
5520
  // src/gallery/client/src/main.ts
5166
5521
  loadPrefs();
5522
+ installUndo();
5167
5523
  $("refresh").onclick = refresh;
5168
5524
  $("select").onclick = () => setSelecting(!ui.selecting);
5169
5525
  $("keys").onclick = openShortcuts;
@@ -5234,6 +5590,11 @@
5234
5590
  openShortcuts();
5235
5591
  return;
5236
5592
  }
5593
+ if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key.toLowerCase() === "z" && !typing && EDITABLE && !$("dialog-host").childNodes.length) {
5594
+ e.preventDefault();
5595
+ void undoLast();
5596
+ return;
5597
+ }
5237
5598
  if (e.key === "Escape") {
5238
5599
  if ($("view-menu").open) {
5239
5600
  e.preventDefault();