pindeck 3.4.0 → 4.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/editor.css CHANGED
@@ -33,6 +33,8 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
33
33
  .brand-pin { width: 14px; height: 14px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); background: var(--orange);
34
34
  box-shadow: inset 0 0 0 4px var(--orange), inset 0 0 0 7px #fff; }
35
35
 
36
+ .version { font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
37
+
36
38
  .app { display: grid; grid-template-columns: 220px minmax(0, 1fr) 400px; height: 100vh; }
37
39
  .pane-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; height: 44px;
38
40
  padding: 0 14px; font-weight: 600; border-bottom: 1px solid var(--line); background: var(--panel); flex: none; }
@@ -173,7 +175,7 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
173
175
  /* the splitter: the panel's top edge, dragged up or down like the notes divider in PowerPoint or Keynote */
174
176
  .notes-split { position: absolute; left: 0; right: 0; top: -4px; height: 8px; z-index: 2; cursor: row-resize; touch-action: none; }
175
177
  .notes-split::after { content: ""; position: absolute; left: 0; right: 0; top: 3px; height: 2px; background: transparent; transition: background .12s; }
176
- .notes-split:hover::after, .notes-split:focus-visible::after, body.resizing-notes .notes-split::after { background: var(--orange); }
178
+ .notes-split:hover::after, .notes-split:focus-visible::after, .notes-split.dragging::after { background: var(--orange); }
177
179
  .notes-split:focus-visible { outline: none; }
178
180
  .notes-panel.collapsed .notes-split { display: none; }
179
181
  body.resizing-notes { cursor: row-resize; user-select: none; }
@@ -244,16 +246,47 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
244
246
  .spinner { width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--orange); border-radius: 50%;
245
247
  animation: spin .8s linear infinite; }
246
248
  @keyframes spin { to { transform: rotate(360deg); } }
247
- .composer { border-top: 1px solid var(--line); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
249
+ .composer { position: relative; border-top: 1px solid var(--line); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
248
250
  .pin-tray:empty { display: none; }
249
251
  .pin-tray { max-height: 96px; overflow-y: auto; }
250
252
  #input { width: 100%; resize: none; font: inherit; border: 1px solid var(--line); border-radius: var(--radius);
251
- padding: 8px 10px; min-height: 64px; max-height: 220px; outline: none; }
253
+ padding: 8px 10px; outline: none; }
252
254
  #input:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(237,140,1,.15); }
253
255
  .composer-row { display: flex; align-items: center; gap: 8px; }
254
256
  .composer-row .spacer { flex: 1; }
255
257
  .check { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
256
258
 
259
+ /* ---- A Quarto deck: rendered from its source, so no hand edits ---- */
260
+ body.quarto #add-slide-btn, body.quarto .slide-list .eye, body.quarto .hand-only { display: none; }
261
+ body:not(.quarto) .quarto-only { display: none; }
262
+ body.quarto #notes:empty::before { content: "Speaker notes come from ::: {.notes} in the Quarto source."; }
263
+
264
+ /* ---- Review comments ---- */
265
+ .count:empty { display: none; }
266
+ .count { background: #1a73e8; color: #fff; border-radius: 8px; padding: 0 5px; font-size: 11px; font-weight: 600; }
267
+ .comments-panel { border-bottom: 1px solid var(--line); max-height: 45%; overflow-y: auto; padding: 8px 12px 10px; background: var(--bg); }
268
+ .comments-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
269
+ .comments-head .spacer { flex: 1; }
270
+ .comments-empty, .comments-file { font-size: 12.5px; margin: 4px 0; color: var(--muted); }
271
+ .comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
272
+ .cmt { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 8px; cursor: pointer; font-size: 13px; }
273
+ .cmt:hover, .cmt.flash { border-color: #1a73e8; }
274
+ .cmt .top { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
275
+ .cmt .n { background: #1a73e8; color: #fff; border-radius: 4px; padding: 0 5px; font-weight: 700; font-size: 11px; }
276
+ .cmt.stale .n { background: #8a8f98; }
277
+ .cmt .top .spacer { flex: 1; }
278
+ .cmt .text { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
279
+ .cmt .edit { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 0; font: inherit; resize: vertical; }
280
+ .cmt .reply { margin: 4px 0 0 10px; padding-left: 6px; border-left: 2px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
281
+ .cmt .reply.mine { border-left-color: #1a73e8; }
282
+ .cmt.resolved .reply { text-decoration: none; }
283
+ .cmt .edit-actions { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; }
284
+ .cmt .flag { margin: 4px 0 0; color: var(--danger); font-size: 12px; }
285
+ .cmt.resolved { opacity: .6; }
286
+ .cmt.resolved .text { text-decoration: line-through; }
287
+ .cmt .done { margin: 4px 0 0; color: var(--ok); font-size: 12px; }
288
+ .cmt.resolved .done { opacity: 1; }
289
+
257
290
  /* ---- Delete confirmation ---- */
258
291
  .delete-dialog { width: min(460px, 92vw); padding: 0; border: 1px solid var(--line); border-radius: 12px;
259
292
  box-shadow: 0 20px 60px rgba(0,0,0,.25); }
package/public/editor.js CHANGED
@@ -122,10 +122,10 @@ const editorHost = {
122
122
  state.current = Math.min(state.current, state.slides.length - 1);
123
123
  if (inf.current !== state.current) bridge.goto(state.current);
124
124
  bridge.setMode(state.mode);
125
- bridge.setEditable(!state.busy);
125
+ applyEditable(bridge);
126
126
  refreshPinsFromSlides();
127
- bridge.renderPins(state.pins);
128
- if (state.sel) bridge.selectEid(state.sel.eid);
127
+ bridge.renderPins(allPins());
128
+ if (state.sel) bridge.selectEid([state.sel.eid, ...(state.sel.also || []).map((a) => a.eid)]);
129
129
  if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
130
130
  renderSlides();
131
131
  renderToolbar();
@@ -144,6 +144,7 @@ const editorHost = {
144
144
  state.pins.push({ ...pin, n });
145
145
  pinsChanged();
146
146
  },
147
+ openComment(id) { openComment(id); },
147
148
  removePin(n) {
148
149
  state.pins = state.pins.filter((p) => p.n !== n);
149
150
  pinsChanged();
@@ -214,7 +215,7 @@ function queueEdit(fn, failMsg = "Could not save the change.") {
214
215
  }
215
216
  function postEdit(op, label) {
216
217
  return queueEdit(async () => {
217
- const res = await api("edit", { ops: [op], label });
218
+ const res = await api("edit", { ops: [].concat(op), label });
218
219
  if (!res.ok) {
219
220
  toast((await res.json().catch(() => ({}))).error || "Could not save the change.");
220
221
  loadMain(); // show the file as it is
@@ -230,8 +231,9 @@ function flushNudge() {
230
231
  }
231
232
  function commitEdit(op, label) {
232
233
  if (label?.nudge) { // arrow keys: one change per burst of key presses
233
- if (nudgeAcc && nudgeAcc.op.eid !== op.eid) flushNudge();
234
- nudgeAcc ??= { name: label.name, dx: 0, dy: 0 };
234
+ const key = [].concat(op).map((o) => o.eid).join(" "); // a group nudge is one op per element
235
+ if (nudgeAcc && nudgeAcc.key !== key) flushNudge();
236
+ nudgeAcc ??= { key, name: label.name, dx: 0, dy: 0 };
235
237
  nudgeAcc.dx += label.dx; nudgeAcc.dy += label.dy; nudgeAcc.op = op;
236
238
  clearTimeout(nudgeTimer);
237
239
  nudgeTimer = setTimeout(flushNudge, 600);
@@ -396,7 +398,7 @@ function notesHtml() {
396
398
  function showNotes(eid, d) {
397
399
  const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
398
400
  notes = { eid, text: "", html: rich == null };
399
- notesBox.contentEditable = !d ? "false" : notes.html ? "plaintext-only" : "true";
401
+ notesBox.contentEditable = !d || state.status?.source ? "false" : notes.html ? "plaintext-only" : "true";
400
402
  if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
401
403
  notes.text = notesValue();
402
404
  }
@@ -481,31 +483,37 @@ function setNotesHeight(h, save = true) {
481
483
  $("notes-split").setAttribute("aria-valuenow", String(h));
482
484
  if (save) try { localStorage.setItem("editor.notesHeight", String(h)); } catch {}
483
485
  }
484
- $("notes-split").addEventListener("pointerdown", (e) => {
485
- if (e.button !== 0) return;
486
- e.preventDefault();
487
- const split = $("notes-split"), y0 = e.clientY, h0 = notesBox.offsetHeight;
488
- split.setPointerCapture(e.pointerId);
489
- document.body.classList.add("resizing-notes");
490
- const move = (ev) => setNotesHeight(h0 - (ev.clientY - y0));
491
- const up = () => {
492
- document.body.classList.remove("resizing-notes");
493
- split.removeEventListener("pointermove", move);
494
- split.removeEventListener("pointerup", up);
495
- split.removeEventListener("pointercancel", up);
496
- };
497
- split.addEventListener("pointermove", move);
498
- split.addEventListener("pointerup", up);
499
- split.addEventListener("pointercancel", up);
500
- });
501
- $("notes-split").addEventListener("dblclick", () => setNotesHeight(NOTES_H));
502
- $("notes-split").addEventListener("keydown", (e) => {
503
- const step = e.shiftKey ? 64 : 16;
504
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
505
- e.preventDefault();
506
- e.stopPropagation(); // not a slide change
507
- setNotesHeight(notesBox.offsetHeight + (e.key === "ArrowUp" ? step : -step));
508
- });
486
+ // drag a splitter (or ↑ ↓ on it) to resize the box below it; double-click for the default size
487
+ function splitter(split, getH, setH, defH) {
488
+ split.addEventListener("pointerdown", (e) => {
489
+ if (e.button !== 0) return;
490
+ e.preventDefault();
491
+ const y0 = e.clientY, h0 = getH();
492
+ split.setPointerCapture(e.pointerId);
493
+ document.body.classList.add("resizing-notes");
494
+ split.classList.add("dragging");
495
+ const move = (ev) => setH(h0 - (ev.clientY - y0));
496
+ const up = () => {
497
+ document.body.classList.remove("resizing-notes");
498
+ split.classList.remove("dragging");
499
+ split.removeEventListener("pointermove", move);
500
+ split.removeEventListener("pointerup", up);
501
+ split.removeEventListener("pointercancel", up);
502
+ };
503
+ split.addEventListener("pointermove", move);
504
+ split.addEventListener("pointerup", up);
505
+ split.addEventListener("pointercancel", up);
506
+ });
507
+ split.addEventListener("dblclick", () => setH(defH));
508
+ split.addEventListener("keydown", (e) => {
509
+ const step = e.shiftKey ? 64 : 16;
510
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
511
+ e.preventDefault();
512
+ e.stopPropagation(); // not a slide change
513
+ setH(getH() + (e.key === "ArrowUp" ? step : -step));
514
+ });
515
+ }
516
+ splitter($("notes-split"), () => notesBox.offsetHeight, setNotesHeight, NOTES_H);
509
517
  setNotesHeight((() => { try { return +localStorage.getItem("editor.notesHeight") || NOTES_H; } catch { return NOTES_H; } })(), false);
510
518
  setNotesOpen((() => { try { return localStorage.getItem("editor.notes") !== "closed"; } catch { return true; } })());
511
519
 
@@ -640,7 +648,7 @@ function refreshPinsFromSlides() {
640
648
  });
641
649
  }
642
650
  function pinsChanged() {
643
- main?.bridge.renderPins(state.pins);
651
+ main?.bridge.renderPins(allPins());
644
652
  renderPinTray();
645
653
  renderSlides();
646
654
  }
@@ -651,8 +659,8 @@ function shortEid(p) {
651
659
  function renderPinTray() {
652
660
  const sel = state.sel;
653
661
  $("pin-tray").innerHTML = (sel ? `
654
- <span class="chip sel" data-sel title="Selected: ${esc(sel.eid)}. The assistant sees it as “this”.">
655
- <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
662
+ <span class="chip sel" data-sel title="Selected: ${esc([sel, ...(sel.also || [])].map((s) => s.eid).join(", "))}. The assistant sees ${sel.also?.length ? "them as “these”" : "it as “this”"}.">
663
+ <span class="n">▢</span> S${sel.slide.index + 1} · ${esc(sel.eid.replace(/^[^/]+\//, ""))}${sel.also?.length ? ` +${sel.also.length}` : ""} <span class="dim">${sel.box.w} × ${sel.box.h}</span>
656
664
  <button class="x" data-unsel aria-label="Deselect">×</button></span>` : "") + state.pins.map((p) => `
657
665
  <span class="chip" data-n="${p.n}" title="${esc(p.eid)}${p.text ? " — " + esc(p.text.slice(0, 80)) : ""}">
658
666
  <span class="n">${p.n}</span> S${p.slide.index + 1} · ${esc(shortEid(p))}${p.word ? ` “${esc(p.word)}”` : ""}
@@ -682,6 +690,146 @@ $("pin-tray").addEventListener("mouseover", (e) => {
682
690
  });
683
691
  $("clear-pins").onclick = () => { state.pins = []; pinsChanged(); };
684
692
 
693
+ /* ---- Review comments (lib/review.mjs): notes on the slides, kept for later ---------------------------
694
+ Shown in the panel under the chat header and as blue markers on the slides (grey: the element or slide
695
+ they were made on is no longer there as it was). They never go into the deck. */
696
+ const MATCHED = new Set(["ok", "moved"]);
697
+ const FLAG = {
698
+ changed: "Unmatched: the element's text has changed since (already addressed?)",
699
+ missing: "Unmatched: the element is no longer on this slide",
700
+ "no-slide": "Unmatched: the slide is no longer in the deck",
701
+ };
702
+ state.comments = [];
703
+ let showResolved = false;
704
+ let editing = null; // { id, text, reply }: the comment being edited or replied to, kept across re-renders (comments refresh while you type)
705
+ const WHO = { you: "You", assistant: "Assistant", "command line": "Command line" };
706
+ try { showResolved = localStorage.getItem("editor.comments.resolved") === "1"; } catch {}
707
+ $("comments-resolved").checked = showResolved;
708
+ const commentWhere = (c) => `S${c.loc.index != null ? c.loc.index + 1 : "?"} · ${c.target.eid ? c.target.tag.split(".")[0] : "slide"}`;
709
+ function commentPins() {
710
+ return state.comments.filter((c) => c.status !== "resolved" && c.loc.slideEid).map((c) => ({
711
+ comment: c.id, n: "C" + c.n, stale: !MATCHED.has(c.loc.status),
712
+ slide: { eid: c.loc.slideEid, index: c.loc.index }, eid: c.loc.eid || c.loc.slideEid,
713
+ point: c.point || { x: 30, y: 30 }, at: c.at || { fx: 0, fy: 0 },
714
+ title: `C${c.n}: ${c.text.slice(0, 160)}${FLAG[c.loc.status] ? "\n" + FLAG[c.loc.status] : ""}`,
715
+ }));
716
+ }
717
+ const allPins = () => [...state.pins, ...commentPins()];
718
+ function applyComments(v) {
719
+ state.comments = v.comments || [];
720
+ const open = state.comments.filter((c) => c.status !== "resolved").length;
721
+ $("comments-count").textContent = open || "";
722
+ $("comments-btn").title = v.problem || "Review comments: notes on the slides, kept for later";
723
+ $("comments-file").textContent = v.file ? `Saved in ${v.file}; the open ones, for an agent outside PinDeck, in ${v.packet}.` : v.problem || "";
724
+ renderComments();
725
+ main?.bridge.renderPins(allPins());
726
+ }
727
+ function renderComments() {
728
+ const list = state.comments.filter((c) => showResolved || c.status !== "resolved");
729
+ $("comments-empty").hidden = list.length > 0;
730
+ if (state.comments.length) $("comments-empty").textContent = "No open comments. Tick “Show resolved” to see the others.";
731
+ $("comments-send").disabled = !state.comments.some((c) => c.status !== "resolved");
732
+ $("comment-list").innerHTML = list.map((c) => `
733
+ <li class="cmt ${MATCHED.has(c.loc.status) ? "" : "stale"} ${c.status === "resolved" ? "resolved" : ""}" data-id="${esc(c.id)}">
734
+ <div class="top"><span class="n">C${c.n}</span> ${esc(commentWhere(c))}${c.slide.title ? ` “${esc(c.slide.title.slice(0, 40))}”` : ""}
735
+ <span class="spacer"></span>
736
+ <button class="small" data-act="reply" aria-label="Reply to comment C${c.n}">Reply</button>
737
+ <button class="small" data-act="edit" aria-label="Edit comment C${c.n}">Edit</button>
738
+ <button class="small" data-act="${c.status === "resolved" ? "open" : "resolved"}">${c.status === "resolved" ? "Reopen" : "Resolve"}</button>
739
+ <button class="small" data-act="delete" aria-label="Delete comment C${c.n}">×</button></div>
740
+ ${editing?.id === c.id && !editing.reply ? editBox(c, "Save", `Text of comment C${c.n}`) : `<p class="text">${esc(c.text)}</p>`}
741
+ ${(c.replies || []).map((r) => `<p class="reply ${r.by === "you" ? "mine" : ""}"><b>${esc(WHO[r.by] || r.by || "Agent")}:</b> ${esc(r.text)}</p>`).join("")}
742
+ ${editing?.id === c.id && editing.reply ? editBox(c, "Reply", `Reply to comment C${c.n}`) : ""}
743
+ ${c.status === "resolved" && c.resolvedBy && c.resolvedBy !== "you" ? `<p class="done">✓ Resolved by the ${esc(c.resolvedBy)}${c.resolution ? `: ${esc(c.resolution)}` : ""}</p>` : ""}
744
+ ${FLAG[c.loc.status] && c.status !== "resolved" ? `<p class="flag">${esc(FLAG[c.loc.status])}</p>` : ""}
745
+ </li>`).join("");
746
+ const box = editing && $("comment-list").querySelector("textarea.edit");
747
+ if (box) { box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
748
+ else editing = null; // the comment is gone, or hidden as resolved
749
+ }
750
+ async function saveEdit() {
751
+ const { id, reply } = editing, text = editing.text.trim();
752
+ if (!text) return toast(reply ? "Write the reply first." : "A comment cannot be empty: delete it with × instead.");
753
+ editing = null;
754
+ if (!(await commentApi("comment-update", reply ? { id, reply: text } : { id, text }))) { editing = { id, text, reply }; renderComments(); }
755
+ }
756
+ const editBox = (c, save, label) => `<textarea class="edit" rows="3" aria-label="${label}">${esc(editing.text)}</textarea>
757
+ <div class="edit-actions"><button class="small" data-act="save">${save}</button> <button class="small" data-act="cancel">Cancel</button>
758
+ <span class="muted">⌘↩ ${save === "Save" ? "saves" : "sends"}, Esc cancels</span></div>`;
759
+ function toggleComments(show = $("comments-panel").hidden) {
760
+ $("comments-panel").hidden = !show;
761
+ $("comments-btn").setAttribute("aria-expanded", String(show));
762
+ }
763
+ function openComment(id) {
764
+ const c = state.comments.find((x) => x.id === id);
765
+ if (!c) return;
766
+ if (c.status === "resolved" && !showResolved) { showResolved = true; $("comments-resolved").checked = true; renderComments(); }
767
+ toggleComments(true);
768
+ const li = $("comment-list").querySelector(`[data-id="${CSS.escape(id)}"]`);
769
+ li?.scrollIntoView({ block: "nearest" });
770
+ li?.classList.add("flash");
771
+ setTimeout(() => li?.classList.remove("flash"), 1200);
772
+ }
773
+ async function commentApi(p, b) {
774
+ const res = await api(p, b);
775
+ const d = await res.json().catch(() => ({}));
776
+ if (!res.ok) { toast(d.error || `Could not save the comment (${res.status}).`); return null; }
777
+ applyComments(d);
778
+ return d;
779
+ }
780
+ async function addComment() {
781
+ const text = $("input").value.trim();
782
+ if (!text) return toast("Write the comment in the box first, then press Comment.");
783
+ if (state.pins.length > 1) return toast("A comment goes on one pin (or none, for the whole slide): remove the others first.");
784
+ const s = state.slides[state.current];
785
+ const d = await commentApi("comment", { text, pin: state.pins[0] || null, slide: s ? { index: state.current, eid: s.eid } : null });
786
+ if (!d) return;
787
+ $("input").value = "";
788
+ autosize();
789
+ state.pins = [];
790
+ pinsChanged();
791
+ openComment(d.id); // the panel opens on the new comment, so it is seen where it was kept
792
+ }
793
+ $("comment").onclick = addComment;
794
+ $("comments-btn").onclick = () => toggleComments();
795
+ $("comments-resolved").onchange = (e) => {
796
+ showResolved = e.target.checked;
797
+ try { localStorage.setItem("editor.comments.resolved", showResolved ? "1" : "0"); } catch {}
798
+ renderComments();
799
+ };
800
+ $("comment-list").addEventListener("click", (e) => {
801
+ const li = e.target.closest(".cmt");
802
+ const c = li && state.comments.find((x) => x.id === li.dataset.id);
803
+ if (!c || e.target.closest("textarea")) return;
804
+ const act = e.target.closest("[data-act]")?.dataset.act;
805
+ if (act === "edit") { editing = { id: c.id, text: c.text }; return renderComments(); }
806
+ if (act === "reply") { editing = { id: c.id, text: "", reply: true }; return renderComments(); }
807
+ if (act === "cancel") { editing = null; return renderComments(); }
808
+ if (act === "save") return saveEdit();
809
+ if (act === "delete") { if (confirm(`Delete comment C${c.n}?`)) commentApi("comment-delete", { id: c.id }); return; }
810
+ if (act) return commentApi("comment-update", { id: c.id, status: act });
811
+ if (c.loc.index == null) return;
812
+ if (c.loc.index !== state.current) goto(c.loc.index);
813
+ if (c.loc.eid) setTimeout(() => main?.bridge.highlight(c.loc.eid), 80);
814
+ });
815
+ $("comment-list").addEventListener("input", (e) => { if (editing && e.target.matches("textarea.edit")) editing.text = e.target.value; });
816
+ $("comment-list").addEventListener("keydown", (e) => {
817
+ if (!editing || !e.target.matches("textarea.edit")) return;
818
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); editing = null; renderComments(); }
819
+ else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
820
+ });
821
+ $("comments-send").onclick = async () => {
822
+ const res = await api("comments-request");
823
+ const d = await res.json().catch(() => ({}));
824
+ if (!res.ok) return toast(d.error || "Could not read the comments.");
825
+ if (!d.text) return toast("There are no open comments.");
826
+ const now = $("input").value.trim();
827
+ $("input").value = now ? `${now}\n\n${d.text}` : d.text;
828
+ autosize();
829
+ $("input").focus();
830
+ toast("The open comments are in the message box: read them over, then Send. Resolve each one once it is done.", { ms: 6000 });
831
+ };
832
+
685
833
  /* ---- Chat ----------------------------------------------------------------------------------------- */
686
834
  const chatEl = $("chat");
687
835
  const entryEls = new Map();
@@ -805,11 +953,17 @@ $("shot-close").onclick = () => $("shot-dialog").close();
805
953
  // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
806
954
  $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
807
955
 
956
+ /* A Quarto deck is rendered from its source: no hand edits, ever (the next render would undo them) */
957
+ const quartoWhy = () => state.status?.source ? `This deck is rendered by Quarto from ${state.status.source}: ask the assistant for changes, or edit ${state.status.source}.` : null;
958
+ function applyEditable(bridge = main?.bridge) {
959
+ bridge?.setEditable(!state.busy && !quartoWhy(), quartoWhy());
960
+ document.body.classList.toggle("quarto", !!quartoWhy());
961
+ }
808
962
  function setBusy(busy) {
809
963
  state.busy = busy;
810
964
  $("notes").readOnly = busy; // hand edits wait until the assistant has finished
811
- $("working").hidden = !busy;
812
- main?.bridge.setEditable(!busy);
965
+ $("working").hidden = !busy && !state.status?.rendering;
966
+ applyEditable();
813
967
  $("send").hidden = busy;
814
968
  $("stop").hidden = !busy;
815
969
  }
@@ -860,11 +1014,23 @@ $("new-chat").onclick = async () => {
860
1014
  if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
861
1015
  await api("new-chat", {});
862
1016
  };
1017
+ // the message box grows with its text from inputMin (set by dragging its top edge) up to 220 px or inputMin
1018
+ const INPUT_H = 96, INPUT_MIN = 48;
1019
+ let inputMin = (() => { try { return +localStorage.getItem("editor.inputHeight") || INPUT_H; } catch { return INPUT_H; } })();
863
1020
  function autosize() {
864
1021
  const t = $("input");
865
1022
  t.style.height = "auto";
866
- t.style.height = Math.min(220, Math.max(64, t.scrollHeight + 2)) + "px";
1023
+ t.style.height = Math.min(Math.max(220, inputMin), Math.max(inputMin, t.scrollHeight + 2)) + "px";
1024
+ }
1025
+ function setInputHeight(h) {
1026
+ const max = Math.max(INPUT_MIN, document.querySelector(".chat-pane").clientHeight * 0.6);
1027
+ inputMin = Math.round(Math.min(max, Math.max(INPUT_MIN, h)));
1028
+ $("input-split").setAttribute("aria-valuenow", String(inputMin));
1029
+ try { localStorage.setItem("editor.inputHeight", String(inputMin)); } catch {}
1030
+ autosize();
867
1031
  }
1032
+ splitter($("input-split"), () => $("input").offsetHeight, setInputHeight, INPUT_H);
1033
+ autosize();
868
1034
  $("input").addEventListener("input", autosize);
869
1035
  $("input").addEventListener("keydown", (e) => {
870
1036
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
@@ -883,7 +1049,7 @@ function handleKey(k, mod = {}) {
883
1049
  if (!(state.sel && state.mode === "select" && main)) $("input").focus();
884
1050
  else main.bridge.editSelection().then((editing) => { if (!editing) $("input").focus(); });
885
1051
  }
886
- else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete(state.sel.eid);
1052
+ else if ((k === "Delete" || k === "Backspace") && state.sel && state.mode === "select") requestDelete([state.sel.eid, ...(state.sel.also || []).map((a) => a.eid)]);
887
1053
  else if (k.startsWith("Arrow") && state.sel && state.mode === "select") {
888
1054
  const step = mod.shift ? 10 : 1;
889
1055
  main?.bridge.nudge(k === "ArrowLeft" ? -step : k === "ArrowRight" ? step : 0, k === "ArrowUp" ? -step : k === "ArrowDown" ? step : 0);
@@ -916,7 +1082,7 @@ function applyStatus(s) {
916
1082
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
917
1083
  if (s.deck) {
918
1084
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
919
- const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
1085
+ const name = s.source || (s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder);
920
1086
  $("deck-name").textContent = name;
921
1087
  $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
922
1088
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
@@ -929,7 +1095,10 @@ function applyStatus(s) {
929
1095
  renderEffortMenu();
930
1096
  $("new-chat").disabled = !!s.busy;
931
1097
  $("stop").hidden = !s.busy || !!s.switching;
932
- $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…";
1098
+ $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : s.busy ? "The assistant is working…"
1099
+ : s.rendering ? `Rendering ${s.source} with Quarto…` : "";
1100
+ $("working").hidden = !s.busy && !s.rendering;
1101
+ if (s.source) $("deck-source").textContent = s.source;
933
1102
  $("conn").classList.toggle("on", !!s.running);
934
1103
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
935
1104
  // only the selected backend's availability matters: Codex missing is no news to someone using Claude
@@ -1005,7 +1174,9 @@ function connect() {
1005
1174
  d.chat.forEach(renderEntry);
1006
1175
  applyStatus(d.status);
1007
1176
  scrollChat();
1177
+ api("comments").then((r) => r.json()).then(applyComments).catch(() => {});
1008
1178
  },
1179
+ comments: (v) => applyComments(v),
1009
1180
  chat(e) {
1010
1181
  const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
1011
1182
  if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
@@ -1238,21 +1409,24 @@ $("provider-switch").addEventListener("close", async () => {
1238
1409
  with it (ticked by default). Undo brings back the element and the files. */
1239
1410
  let deleting = false;
1240
1411
  async function requestDelete(eid) {
1241
- if (deleting || !eid) return;
1412
+ const eids = [].concat(eid || []);
1413
+ if (deleting || !eids.length) return;
1414
+ if (quartoWhy()) return toast(quartoWhy());
1242
1415
  if (state.busy) return toast("The assistant is working. Wait until it has finished.");
1243
1416
  deleting = true;
1244
1417
  try {
1245
1418
  flushNudge();
1246
1419
  await editChain;
1247
- const res = await api(`delete-info?${new URLSearchParams({ eid })}`);
1420
+ const res = await api(`delete-info?${new URLSearchParams(eids.map((e) => ["eid", e]))}`);
1248
1421
  const info = await res.json().catch(() => ({}));
1249
1422
  if (!res.ok) return toast(info.error || "Cannot delete this element.");
1250
- const name = info.eid.replace(/^[^/]+\//, "/");
1423
+ const many = info.targets.length > 1;
1424
+ const name = info.targets.map((t) => t.eid.replace(/^[^/]+\//, "/")).join(", ");
1251
1425
  const slide = state.slides.findIndex((s) => info.eid === s.eid || info.eid.startsWith(s.eid + "/"));
1252
- const isFig = info.tag.startsWith("figure") && info.eid !== eid;
1253
- $("delete-title").textContent = isFig ? "Delete this figure?" : `Delete ${name}?`;
1254
- $("delete-what").textContent = `${isFig ? `The picture and its caption (${name})` : info.tag}` +
1255
- (slide >= 0 ? ` on slide ${slide + 1}` : "") + " will be removed from the slide.";
1426
+ const isFig = !many && info.tag.startsWith("figure") && info.eid !== eids[0];
1427
+ $("delete-title").textContent = many ? `Delete ${info.targets.length} elements?` : isFig ? "Delete this figure?" : `Delete ${name}?`;
1428
+ $("delete-what").textContent = `${many ? `${name}` : isFig ? `The picture and its caption (${name})` : info.tag}` +
1429
+ (slide >= 0 ? ` on slide ${slide + 1}` : "") + ` will be removed from the slide.`;
1256
1430
  $("delete-files").innerHTML = info.files.map((f, i) => f.usedElsewhere
1257
1431
  ? `<div class="file kept"><span>📄</span><span><code>${esc(f.rel)}</code>
1258
1432
  <span class="why">${f.usedBy?.length ? `Also used by ${esc(f.usedBy.join(", "))}` : "Still used elsewhere in the deck"}, so it is kept.</span></span></div>`
@@ -1267,7 +1441,7 @@ async function requestDelete(eid) {
1267
1441
  });
1268
1442
  if (choice !== "delete") return;
1269
1443
  const files = [...$("delete-files").querySelectorAll("input[data-file]:checked")].map((c) => c.dataset.file);
1270
- const r2 = await api("delete", { eid, files });
1444
+ const r2 = await api("delete", { eids, files });
1271
1445
  const d2 = await r2.json().catch(() => ({}));
1272
1446
  if (!r2.ok) return toast(d2.error || "Could not delete the element.");
1273
1447
  main?.bridge.deselect();
package/public/index.html CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  <!-- Left: slides -->
16
16
  <nav class="slides-pane" aria-label="Slides">
17
- <div class="pane-head"><span class="brand"><span class="brand-pin" aria-hidden="true"></span>PinDeck</span></div>
17
+ <div class="pane-head"><span class="brand"><span class="brand-pin" aria-hidden="true"></span>PinDeck</span><span class="version muted">v{{VERSION}}</span></div>
18
18
  <div class="slides-actions menu-wrap">
19
19
  <button id="add-slide-btn" class="add-slide" aria-haspopup="menu" aria-expanded="false" title="Add a slide after the current one">+ New slide</button>
20
20
  <div id="add-slide-menu" class="menu left" role="menu" hidden>
@@ -91,25 +91,43 @@
91
91
  </span>
92
92
  <span class="head-actions">
93
93
  <span id="conn" class="conn" title="Backend session"></span>
94
+ <button id="comments-btn" class="small" aria-expanded="false" aria-controls="comments-panel"
95
+ title="Review comments: notes on the slides, kept for later">Comments <span id="comments-count" class="count"></span></button>
94
96
  <button id="new-chat" class="small" title="Start a new conversation">New chat</button>
95
97
  </span>
96
98
  </div>
99
+ <section id="comments-panel" class="comments-panel" aria-label="Review comments" hidden>
100
+ <div class="comments-head">
101
+ <strong>Comments</strong>
102
+ <label class="check"><input type="checkbox" id="comments-resolved"> Show resolved</label>
103
+ <span class="spacer"></span>
104
+ <button id="comments-send" class="small" title="Put the open comments into the message box as one request">Process Comments</button>
105
+ </div>
106
+ <p id="comments-empty" class="muted comments-empty">No comments yet. Write a note in the box below, pin an element (or not, for the
107
+ whole slide) and press <strong>Comment</strong> instead of Send: it is kept here, and on the slide, for later.</p>
108
+ <ol id="comment-list" class="comment-list"></ol>
109
+ <p id="comments-file" class="muted comments-file"></p>
110
+ </section>
97
111
  <div id="chat" class="chat" aria-live="polite">
98
112
  <div class="empty" id="chat-empty">
99
113
  <p><strong>Pin, then ask.</strong></p>
100
114
  <p>Press <kbd>P</kbd> and click elements on the slide. Each click gets a number. Then write what to change, e.g.
101
115
  <em>“1: align with 2, make 3 smaller”</em>.</p>
102
- <p>Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
116
+ <p class="hand-only">Or edit yourself: click to select, drag to move, pull a handle to resize, arrow keys to nudge,
103
117
  double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
104
118
  <p>Add figures, audio, a PDF or a screenshot with <strong>+</strong>, or drag and drop or paste a file; the assistant can put it on a slide
105
119
  or, for a paper or report, take the details from it.</p>
106
- <p>The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
120
+ <p class="hand-only">The assistant edits <code id="deck-file">index.html</code>, <code>theme.css</code> and the deck's other files, checks the layout, and the slide updates here.
107
121
  <code>brand.css</code> is locked.</p>
122
+ <p class="quarto-only">This deck is made with Quarto: the assistant edits <code id="deck-source"></code> and its stylesheets, and PinDeck renders
123
+ the deck after every change. Edit the source yourself in any editor; the slide updates here too.</p>
108
124
  </div>
109
125
  </div>
110
126
  <div id="drop-hint" class="drop-hint" hidden><div>Drop figures, audio or PDFs to attach them</div></div>
111
127
  <div id="working" class="working" hidden><span class="spinner"></span> <span id="working-text">The assistant is working…</span></div>
112
128
  <div class="composer">
129
+ <div id="input-split" class="notes-split" role="separator" aria-orientation="horizontal" aria-controls="input" tabindex="0"
130
+ title="Drag to resize the message box (double-click: default size)" aria-label="Resize the message box"></div>
113
131
  <div id="pin-tray" class="pin-tray"></div>
114
132
  <div id="attach-tray" class="attach-tray"></div>
115
133
  <textarea id="input" rows="3" placeholder="Ask for a change… refer to pins by number (1, 2, …)"></textarea>
@@ -125,6 +143,7 @@
125
143
  <input type="file" id="file-input" multiple accept="image/*,audio/*,.svg,.pdf,application/pdf" hidden>
126
144
  <span class="spacer"></span>
127
145
  <button id="clear-pins" class="small" hidden>Clear pins</button>
146
+ <button id="comment" title="Keep the text as a review comment on the pin (or on the slide), instead of sending it">Comment</button>
128
147
  <button id="send" class="primary">Send</button>
129
148
  <button id="stop" class="danger" hidden>Stop</button>
130
149
  </div>