pindeck 3.3.0 → 4.0.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.js CHANGED
@@ -122,9 +122,9 @@ 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);
127
+ bridge.renderPins(allPins());
128
128
  if (state.sel) bridge.selectEid(state.sel.eid);
129
129
  if (state.pendingTitleEdit) { state.pendingTitleEdit = false; setTimeout(() => bridge.editTitle(), 50); }
130
130
  renderSlides();
@@ -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();
@@ -365,16 +366,44 @@ async function moveSlideTo(from, to) { // to: insertion index in the current l
365
366
  }
366
367
 
367
368
  /* ---- Speaker notes: the field under the slide -------------------------------------------------------
368
- Shows the current slide's <aside class="notes"> and saves it as a hand edit when the field loses focus
369
- or on ⌘Enter; Esc puts back what the file says. Notes with markup other than line breaks are edited
370
- as their HTML ("HTML" in the header). Typing is never overwritten by a reload. */
369
+ Shows the current slide's <aside class="notes"> as formatted text (⌘B ⌘I ⌘U; Enter is a line break) and
370
+ saves its HTML as a hand edit when the field loses focus or on ⌘Enter; Esc puts back what the file says.
371
+ Notes with markup the field cannot show safely (other tags or attributes) are edited as their HTML
372
+ ("HTML" in the header). Typing is never overwritten by a reload. */
373
+ const NOTE_TAGS = new Set(["P", "BR", "B", "STRONG", "I", "EM", "U", "S", "SUB", "SUP", "CODE", "SPAN", "SMALL", "MARK",
374
+ "UL", "OL", "LI", "A", "BLOCKQUOTE", "DIV"]);
375
+ const safeNoteAttr = (el, a) => a.name === "class" || (a.name === "href" && el.tagName === "A" && /^(https?:|mailto:|#)/i.test(a.value.trim()));
376
+ /* The notes' HTML when the field can show it unchanged (only harmless tags and attributes), else null. Parsed inert. */
377
+ function richNotes(html) {
378
+ const body = new DOMParser().parseFromString("<body>" + html, "text/html").body; // <body>: a leading <style> or <script> stays in it, to be seen
379
+ for (const el of body.querySelectorAll("*"))
380
+ if (!NOTE_TAGS.has(el.tagName) || [...el.attributes].some((a) => !safeNoteAttr(el, a))) return null;
381
+ return body.innerHTML;
382
+ }
371
383
  const notesBox = $("notes");
372
- let notes = { eid: null, text: "", html: false }; // the notes as the file has them
384
+ let notes = { eid: null, text: "", html: false }; // the field's content as loaded; html: edited as raw HTML
373
385
  let notesSeq = 0;
374
- const notesDirty = () => !!notes.eid && notesBox.value.trim() !== notes.text.trim();
386
+ const notesValue = () => notes.html ? notesBox.textContent : notesBox.innerHTML;
387
+ const notesDirty = () => !!notes.eid && notesValue().trim() !== notes.text.trim();
388
+ /* What is saved: the raw HTML as typed, or the field's HTML without the browser's own styling of edits */
389
+ function notesHtml() {
390
+ if (notes.html) return notesBox.textContent;
391
+ if (!notesBox.textContent.trim()) return "";
392
+ const c = notesBox.cloneNode(true);
393
+ c.querySelectorAll("[style]").forEach((el) => el.removeAttribute("style"));
394
+ return c.innerHTML.replace(/(\s*<br>)+\s*$/, "");
395
+ }
396
+ /* Shows the notes `d` ({ text, html } from the server, or null: none) of slide `eid` */
397
+ function showNotes(eid, d) {
398
+ const rich = !d ? "" : d.html ? richNotes(d.text || "") : (d.text || "").split("\n").map(esc).join("<br>");
399
+ notes = { eid, text: "", html: rich == null };
400
+ notesBox.contentEditable = !d || state.status?.source ? "false" : notes.html ? "plaintext-only" : "true";
401
+ if (notes.html) notesBox.textContent = d.text; else notesBox.innerHTML = rich;
402
+ notes.text = notesValue();
403
+ }
375
404
  function notesStatus(msg) {
376
405
  $("notes-status").textContent = msg ?? (notes.html ? "HTML" : "");
377
- $("notes-status").title = msg ? "" : notes.html ? "These notes contain formatting, so you edit them as HTML" : "";
406
+ $("notes-status").title = msg ? "" : notes.html ? "These notes contain markup the field cannot show, so you edit them as HTML" : "";
378
407
  }
379
408
  async function loadNotes() {
380
409
  const eid = state.slides[state.current]?.eid;
@@ -386,19 +415,15 @@ async function loadNotes() {
386
415
  const d = res?.ok ? await res.json().catch(() => null) : null;
387
416
  if (seq !== notesSeq || document.activeElement === notesBox || notesDirty()) return;
388
417
  if (!d) { // detach from the previous slide, so its notes are not shown, or saved over, as this one's
389
- notes = { eid: null, text: "", html: false };
390
- notesBox.value = "";
391
- notesBox.disabled = true;
418
+ showNotes(null, null);
392
419
  return notesStatus("Could not load");
393
420
  }
394
- notes = { eid, text: d.text || "", html: !!d.html };
395
- notesBox.value = notes.text;
396
- notesBox.disabled = false;
421
+ showNotes(eid, d);
397
422
  notesStatus();
398
423
  }
399
424
  function saveNotes() {
400
425
  if (!notesDirty()) return Promise.resolve(true);
401
- const edit = { eid: notes.eid, text: notesBox.value, html: notes.html };
426
+ const edit = { eid: notes.eid, text: notesHtml(), html: true }, shown = notesValue();
402
427
  flushNudge();
403
428
  const run = async () => {
404
429
  notesStatus("Saving…");
@@ -412,7 +437,7 @@ function saveNotes() {
412
437
  toast(d?.error || "Could not save the notes.");
413
438
  return false;
414
439
  }
415
- if (notes.eid === edit.eid) notes.text = edit.text;
440
+ if (notes.eid === edit.eid) notes.text = shown;
416
441
  notesStatus("Saved");
417
442
  setTimeout(() => { if ($("notes-status").textContent === "Saved") notesStatus(); }, 1500);
418
443
  return true;
@@ -424,12 +449,23 @@ notesBox.addEventListener("blur", async () => {
424
449
  });
425
450
  notesBox.addEventListener("keydown", (e) => {
426
451
  if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); notesBox.blur(); }
427
- else if (e.key === "Escape") { e.preventDefault(); notesBox.value = notes.text; notesBox.blur(); }
452
+ else if (e.key === "Escape") {
453
+ e.preventDefault();
454
+ if (notes.html) notesBox.textContent = notes.text; else notesBox.innerHTML = notes.text;
455
+ notesBox.blur();
456
+ }
457
+ // a <br>, as the notes have them, not a new <div>
458
+ else if (e.key === "Enter" && !notes.html) { e.preventDefault(); document.execCommand("insertLineBreak"); }
428
459
  });
460
+ notesBox.addEventListener("paste", (e) => { // pasted text comes in plain: no styles or markup from elsewhere
461
+ e.preventDefault();
462
+ document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
463
+ });
464
+ notesBox.addEventListener("input", () => { if (notesBox.innerHTML === "<br>") notesBox.innerHTML = ""; }); // placeholder again
429
465
  // closing the tab while typing: send what is there (sendBeacon cannot set headers, hence the token in the URL)
430
466
  addEventListener("pagehide", () => {
431
467
  if (notesDirty() && !state.busy)
432
- navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesBox.value, html: notes.html })]));
468
+ navigator.sendBeacon(BASE + "api/notes", new Blob([JSON.stringify({ eid: notes.eid, text: notesHtml(), html: true })]));
433
469
  });
434
470
  function setNotesOpen(open) {
435
471
  $("notes-panel").classList.toggle("collapsed", !open);
@@ -446,31 +482,37 @@ function setNotesHeight(h, save = true) {
446
482
  $("notes-split").setAttribute("aria-valuenow", String(h));
447
483
  if (save) try { localStorage.setItem("editor.notesHeight", String(h)); } catch {}
448
484
  }
449
- $("notes-split").addEventListener("pointerdown", (e) => {
450
- if (e.button !== 0) return;
451
- e.preventDefault();
452
- const split = $("notes-split"), y0 = e.clientY, h0 = notesBox.offsetHeight;
453
- split.setPointerCapture(e.pointerId);
454
- document.body.classList.add("resizing-notes");
455
- const move = (ev) => setNotesHeight(h0 - (ev.clientY - y0));
456
- const up = () => {
457
- document.body.classList.remove("resizing-notes");
458
- split.removeEventListener("pointermove", move);
459
- split.removeEventListener("pointerup", up);
460
- split.removeEventListener("pointercancel", up);
461
- };
462
- split.addEventListener("pointermove", move);
463
- split.addEventListener("pointerup", up);
464
- split.addEventListener("pointercancel", up);
465
- });
466
- $("notes-split").addEventListener("dblclick", () => setNotesHeight(NOTES_H));
467
- $("notes-split").addEventListener("keydown", (e) => {
468
- const step = e.shiftKey ? 64 : 16;
469
- if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
470
- e.preventDefault();
471
- e.stopPropagation(); // not a slide change
472
- setNotesHeight(notesBox.offsetHeight + (e.key === "ArrowUp" ? step : -step));
473
- });
485
+ // drag a splitter (or ↑ ↓ on it) to resize the box below it; double-click for the default size
486
+ function splitter(split, getH, setH, defH) {
487
+ split.addEventListener("pointerdown", (e) => {
488
+ if (e.button !== 0) return;
489
+ e.preventDefault();
490
+ const y0 = e.clientY, h0 = getH();
491
+ split.setPointerCapture(e.pointerId);
492
+ document.body.classList.add("resizing-notes");
493
+ split.classList.add("dragging");
494
+ const move = (ev) => setH(h0 - (ev.clientY - y0));
495
+ const up = () => {
496
+ document.body.classList.remove("resizing-notes");
497
+ split.classList.remove("dragging");
498
+ split.removeEventListener("pointermove", move);
499
+ split.removeEventListener("pointerup", up);
500
+ split.removeEventListener("pointercancel", up);
501
+ };
502
+ split.addEventListener("pointermove", move);
503
+ split.addEventListener("pointerup", up);
504
+ split.addEventListener("pointercancel", up);
505
+ });
506
+ split.addEventListener("dblclick", () => setH(defH));
507
+ split.addEventListener("keydown", (e) => {
508
+ const step = e.shiftKey ? 64 : 16;
509
+ if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
510
+ e.preventDefault();
511
+ e.stopPropagation(); // not a slide change
512
+ setH(getH() + (e.key === "ArrowUp" ? step : -step));
513
+ });
514
+ }
515
+ splitter($("notes-split"), () => notesBox.offsetHeight, setNotesHeight, NOTES_H);
474
516
  setNotesHeight((() => { try { return +localStorage.getItem("editor.notesHeight") || NOTES_H; } catch { return NOTES_H; } })(), false);
475
517
  setNotesOpen((() => { try { return localStorage.getItem("editor.notes") !== "closed"; } catch { return true; } })());
476
518
 
@@ -580,8 +622,13 @@ $("export-menu").addEventListener("click", async (e) => {
580
622
  }
581
623
  });
582
624
 
625
+ /* Present on localhost, at the deck's view path: no login there, so no sandbox, and reveal.js's speaker view (S)
626
+ works. noopener: the deck's scripts get no handle on this editor. */
583
627
  $("present").onclick = () => {
584
- window.open(`${BASE}deck/${state.slides[state.current]?.hash || ""}`, "_blank");
628
+ const view = state.status?.view || "";
629
+ const u = new URL(`${BASE}${view}deck/${state.slides[state.current]?.hash || ""}`, location.href);
630
+ if (view) u.hostname = "localhost";
631
+ window.open(u, "_blank", "noopener");
585
632
  };
586
633
 
587
634
  /* ---- Pins --------------------------------------------------------------------------------------- */
@@ -600,7 +647,7 @@ function refreshPinsFromSlides() {
600
647
  });
601
648
  }
602
649
  function pinsChanged() {
603
- main?.bridge.renderPins(state.pins);
650
+ main?.bridge.renderPins(allPins());
604
651
  renderPinTray();
605
652
  renderSlides();
606
653
  }
@@ -642,6 +689,146 @@ $("pin-tray").addEventListener("mouseover", (e) => {
642
689
  });
643
690
  $("clear-pins").onclick = () => { state.pins = []; pinsChanged(); };
644
691
 
692
+ /* ---- Review comments (lib/review.mjs): notes on the slides, kept for later ---------------------------
693
+ Shown in the panel under the chat header and as blue markers on the slides (grey: the element or slide
694
+ they were made on is no longer there as it was). They never go into the deck. */
695
+ const MATCHED = new Set(["ok", "moved"]);
696
+ const FLAG = {
697
+ changed: "Unmatched: the element's text has changed since (already addressed?)",
698
+ missing: "Unmatched: the element is no longer on this slide",
699
+ "no-slide": "Unmatched: the slide is no longer in the deck",
700
+ };
701
+ state.comments = [];
702
+ let showResolved = false;
703
+ let editing = null; // { id, text, reply }: the comment being edited or replied to, kept across re-renders (comments refresh while you type)
704
+ const WHO = { you: "You", assistant: "Assistant", "command line": "Command line" };
705
+ try { showResolved = localStorage.getItem("editor.comments.resolved") === "1"; } catch {}
706
+ $("comments-resolved").checked = showResolved;
707
+ const commentWhere = (c) => `S${c.loc.index != null ? c.loc.index + 1 : "?"} · ${c.target.eid ? c.target.tag.split(".")[0] : "slide"}`;
708
+ function commentPins() {
709
+ return state.comments.filter((c) => c.status !== "resolved" && c.loc.slideEid).map((c) => ({
710
+ comment: c.id, n: "C" + c.n, stale: !MATCHED.has(c.loc.status),
711
+ slide: { eid: c.loc.slideEid, index: c.loc.index }, eid: c.loc.eid || c.loc.slideEid,
712
+ point: c.point || { x: 30, y: 30 }, at: c.at || { fx: 0, fy: 0 },
713
+ title: `C${c.n}: ${c.text.slice(0, 160)}${FLAG[c.loc.status] ? "\n" + FLAG[c.loc.status] : ""}`,
714
+ }));
715
+ }
716
+ const allPins = () => [...state.pins, ...commentPins()];
717
+ function applyComments(v) {
718
+ state.comments = v.comments || [];
719
+ const open = state.comments.filter((c) => c.status !== "resolved").length;
720
+ $("comments-count").textContent = open || "";
721
+ $("comments-btn").title = v.problem || "Review comments: notes on the slides, kept for later";
722
+ $("comments-file").textContent = v.file ? `Saved in ${v.file}; the open ones, for an agent outside PinDeck, in ${v.packet}.` : v.problem || "";
723
+ renderComments();
724
+ main?.bridge.renderPins(allPins());
725
+ }
726
+ function renderComments() {
727
+ const list = state.comments.filter((c) => showResolved || c.status !== "resolved");
728
+ $("comments-empty").hidden = list.length > 0;
729
+ if (state.comments.length) $("comments-empty").textContent = "No open comments. Tick “Show resolved” to see the others.";
730
+ $("comments-send").disabled = !state.comments.some((c) => c.status !== "resolved");
731
+ $("comment-list").innerHTML = list.map((c) => `
732
+ <li class="cmt ${MATCHED.has(c.loc.status) ? "" : "stale"} ${c.status === "resolved" ? "resolved" : ""}" data-id="${esc(c.id)}">
733
+ <div class="top"><span class="n">C${c.n}</span> ${esc(commentWhere(c))}${c.slide.title ? ` “${esc(c.slide.title.slice(0, 40))}”` : ""}
734
+ <span class="spacer"></span>
735
+ <button class="small" data-act="reply" aria-label="Reply to comment C${c.n}">Reply</button>
736
+ <button class="small" data-act="edit" aria-label="Edit comment C${c.n}">Edit</button>
737
+ <button class="small" data-act="${c.status === "resolved" ? "open" : "resolved"}">${c.status === "resolved" ? "Reopen" : "Resolve"}</button>
738
+ <button class="small" data-act="delete" aria-label="Delete comment C${c.n}">×</button></div>
739
+ ${editing?.id === c.id && !editing.reply ? editBox(c, "Save", `Text of comment C${c.n}`) : `<p class="text">${esc(c.text)}</p>`}
740
+ ${(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("")}
741
+ ${editing?.id === c.id && editing.reply ? editBox(c, "Reply", `Reply to comment C${c.n}`) : ""}
742
+ ${c.status === "resolved" && c.resolvedBy && c.resolvedBy !== "you" ? `<p class="done">✓ Resolved by the ${esc(c.resolvedBy)}${c.resolution ? `: ${esc(c.resolution)}` : ""}</p>` : ""}
743
+ ${FLAG[c.loc.status] && c.status !== "resolved" ? `<p class="flag">${esc(FLAG[c.loc.status])}</p>` : ""}
744
+ </li>`).join("");
745
+ const box = editing && $("comment-list").querySelector("textarea.edit");
746
+ if (box) { box.focus(); box.setSelectionRange(box.value.length, box.value.length); }
747
+ else editing = null; // the comment is gone, or hidden as resolved
748
+ }
749
+ async function saveEdit() {
750
+ const { id, reply } = editing, text = editing.text.trim();
751
+ if (!text) return toast(reply ? "Write the reply first." : "A comment cannot be empty: delete it with × instead.");
752
+ editing = null;
753
+ if (!(await commentApi("comment-update", reply ? { id, reply: text } : { id, text }))) { editing = { id, text, reply }; renderComments(); }
754
+ }
755
+ const editBox = (c, save, label) => `<textarea class="edit" rows="3" aria-label="${label}">${esc(editing.text)}</textarea>
756
+ <div class="edit-actions"><button class="small" data-act="save">${save}</button> <button class="small" data-act="cancel">Cancel</button>
757
+ <span class="muted">⌘↩ ${save === "Save" ? "saves" : "sends"}, Esc cancels</span></div>`;
758
+ function toggleComments(show = $("comments-panel").hidden) {
759
+ $("comments-panel").hidden = !show;
760
+ $("comments-btn").setAttribute("aria-expanded", String(show));
761
+ }
762
+ function openComment(id) {
763
+ const c = state.comments.find((x) => x.id === id);
764
+ if (!c) return;
765
+ if (c.status === "resolved" && !showResolved) { showResolved = true; $("comments-resolved").checked = true; renderComments(); }
766
+ toggleComments(true);
767
+ const li = $("comment-list").querySelector(`[data-id="${CSS.escape(id)}"]`);
768
+ li?.scrollIntoView({ block: "nearest" });
769
+ li?.classList.add("flash");
770
+ setTimeout(() => li?.classList.remove("flash"), 1200);
771
+ }
772
+ async function commentApi(p, b) {
773
+ const res = await api(p, b);
774
+ const d = await res.json().catch(() => ({}));
775
+ if (!res.ok) { toast(d.error || `Could not save the comment (${res.status}).`); return null; }
776
+ applyComments(d);
777
+ return d;
778
+ }
779
+ async function addComment() {
780
+ const text = $("input").value.trim();
781
+ if (!text) return toast("Write the comment in the box first, then press Comment.");
782
+ if (state.pins.length > 1) return toast("A comment goes on one pin (or none, for the whole slide): remove the others first.");
783
+ const s = state.slides[state.current];
784
+ const d = await commentApi("comment", { text, pin: state.pins[0] || null, slide: s ? { index: state.current, eid: s.eid } : null });
785
+ if (!d) return;
786
+ $("input").value = "";
787
+ autosize();
788
+ state.pins = [];
789
+ pinsChanged();
790
+ openComment(d.id); // the panel opens on the new comment, so it is seen where it was kept
791
+ }
792
+ $("comment").onclick = addComment;
793
+ $("comments-btn").onclick = () => toggleComments();
794
+ $("comments-resolved").onchange = (e) => {
795
+ showResolved = e.target.checked;
796
+ try { localStorage.setItem("editor.comments.resolved", showResolved ? "1" : "0"); } catch {}
797
+ renderComments();
798
+ };
799
+ $("comment-list").addEventListener("click", (e) => {
800
+ const li = e.target.closest(".cmt");
801
+ const c = li && state.comments.find((x) => x.id === li.dataset.id);
802
+ if (!c || e.target.closest("textarea")) return;
803
+ const act = e.target.closest("[data-act]")?.dataset.act;
804
+ if (act === "edit") { editing = { id: c.id, text: c.text }; return renderComments(); }
805
+ if (act === "reply") { editing = { id: c.id, text: "", reply: true }; return renderComments(); }
806
+ if (act === "cancel") { editing = null; return renderComments(); }
807
+ if (act === "save") return saveEdit();
808
+ if (act === "delete") { if (confirm(`Delete comment C${c.n}?`)) commentApi("comment-delete", { id: c.id }); return; }
809
+ if (act) return commentApi("comment-update", { id: c.id, status: act });
810
+ if (c.loc.index == null) return;
811
+ if (c.loc.index !== state.current) goto(c.loc.index);
812
+ if (c.loc.eid) setTimeout(() => main?.bridge.highlight(c.loc.eid), 80);
813
+ });
814
+ $("comment-list").addEventListener("input", (e) => { if (editing && e.target.matches("textarea.edit")) editing.text = e.target.value; });
815
+ $("comment-list").addEventListener("keydown", (e) => {
816
+ if (!editing || !e.target.matches("textarea.edit")) return;
817
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); editing = null; renderComments(); }
818
+ else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); saveEdit(); }
819
+ });
820
+ $("comments-send").onclick = async () => {
821
+ const res = await api("comments-request");
822
+ const d = await res.json().catch(() => ({}));
823
+ if (!res.ok) return toast(d.error || "Could not read the comments.");
824
+ if (!d.text) return toast("There are no open comments.");
825
+ const now = $("input").value.trim();
826
+ $("input").value = now ? `${now}\n\n${d.text}` : d.text;
827
+ autosize();
828
+ $("input").focus();
829
+ toast("The open comments are in the message box: read them over, then Send. Resolve each one once it is done.", { ms: 6000 });
830
+ };
831
+
645
832
  /* ---- Chat ----------------------------------------------------------------------------------------- */
646
833
  const chatEl = $("chat");
647
834
  const entryEls = new Map();
@@ -765,11 +952,17 @@ $("shot-close").onclick = () => $("shot-dialog").close();
765
952
  // Esc closes it (the browser does that for a modal dialog), and so does a click beside the picture
766
953
  $("shot-dialog").addEventListener("click", (e) => { if (e.target === $("shot-dialog")) $("shot-dialog").close(); });
767
954
 
955
+ /* A Quarto deck is rendered from its source: no hand edits, ever (the next render would undo them) */
956
+ 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;
957
+ function applyEditable(bridge = main?.bridge) {
958
+ bridge?.setEditable(!state.busy && !quartoWhy(), quartoWhy());
959
+ document.body.classList.toggle("quarto", !!quartoWhy());
960
+ }
768
961
  function setBusy(busy) {
769
962
  state.busy = busy;
770
963
  $("notes").readOnly = busy; // hand edits wait until the assistant has finished
771
- $("working").hidden = !busy;
772
- main?.bridge.setEditable(!busy);
964
+ $("working").hidden = !busy && !state.status?.rendering;
965
+ applyEditable();
773
966
  $("send").hidden = busy;
774
967
  $("stop").hidden = !busy;
775
968
  }
@@ -820,11 +1013,23 @@ $("new-chat").onclick = async () => {
820
1013
  if (!confirm("Start a new conversation? The assistant forgets this chat; your slides, and Undo and Redo, stay as they are.")) return;
821
1014
  await api("new-chat", {});
822
1015
  };
1016
+ // the message box grows with its text from inputMin (set by dragging its top edge) up to 220 px or inputMin
1017
+ const INPUT_H = 96, INPUT_MIN = 48;
1018
+ let inputMin = (() => { try { return +localStorage.getItem("editor.inputHeight") || INPUT_H; } catch { return INPUT_H; } })();
823
1019
  function autosize() {
824
1020
  const t = $("input");
825
1021
  t.style.height = "auto";
826
- t.style.height = Math.min(220, Math.max(64, t.scrollHeight + 2)) + "px";
1022
+ t.style.height = Math.min(Math.max(220, inputMin), Math.max(inputMin, t.scrollHeight + 2)) + "px";
1023
+ }
1024
+ function setInputHeight(h) {
1025
+ const max = Math.max(INPUT_MIN, document.querySelector(".chat-pane").clientHeight * 0.6);
1026
+ inputMin = Math.round(Math.min(max, Math.max(INPUT_MIN, h)));
1027
+ $("input-split").setAttribute("aria-valuenow", String(inputMin));
1028
+ try { localStorage.setItem("editor.inputHeight", String(inputMin)); } catch {}
1029
+ autosize();
827
1030
  }
1031
+ splitter($("input-split"), () => $("input").offsetHeight, setInputHeight, INPUT_H);
1032
+ autosize();
828
1033
  $("input").addEventListener("input", autosize);
829
1034
  $("input").addEventListener("keydown", (e) => {
830
1035
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); send(); }
@@ -852,7 +1057,7 @@ function handleKey(k, mod = {}) {
852
1057
  else if (k === "ArrowRight" || k === "ArrowDown" || k === "PageDown" || k === " ") goto(state.current + 1);
853
1058
  }
854
1059
  document.addEventListener("keydown", (e) => {
855
- if (e.target.closest?.("input, textarea, dialog")) return;
1060
+ if (e.target.closest?.("input, textarea, dialog, [contenteditable]")) return;
856
1061
  // a menu and the button that opens it keep Enter, Space and the arrow keys (a menu opens, an item is chosen):
857
1062
  // no slide change, no jump to the chat. Other buttons do not: after a click on ‹ › the focus stays there
858
1063
  // while the slide is clicked (the slide frame keeps it), and Enter must still edit the selected text.
@@ -876,7 +1081,7 @@ function applyStatus(s) {
876
1081
  // the deck's name in the header ("ocean-example", or "talk.html" for a named deck file), its full path on hover
877
1082
  if (s.deck) {
878
1083
  const folder = s.deck.split(/[\\/]/).filter(Boolean).pop() || s.deck;
879
- const name = s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder;
1084
+ const name = s.source || (s.deckFile && s.deckFile !== "index.html" ? s.deckFile : folder);
880
1085
  $("deck-name").textContent = name;
881
1086
  $("deck-btn").title = `${s.deck}/${s.deckFile || "index.html"}${FRONT ? " (click for other decks)" : ""}`;
882
1087
  if ($("deck-file")) $("deck-file").textContent = s.deckFile || "index.html";
@@ -889,7 +1094,10 @@ function applyStatus(s) {
889
1094
  renderEffortMenu();
890
1095
  $("new-chat").disabled = !!s.busy;
891
1096
  $("stop").hidden = !s.busy || !!s.switching;
892
- $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : "The assistant is working…";
1097
+ $("working-text").textContent = s.switching ? "Updating model…" : s.preparing ? "Taking the screenshot…" : s.busy ? "The assistant is working…"
1098
+ : s.rendering ? `Rendering ${s.source} with Quarto…` : "";
1099
+ $("working").hidden = !s.busy && !s.rendering;
1100
+ if (s.source) $("deck-source").textContent = s.source;
893
1101
  $("conn").classList.toggle("on", !!s.running);
894
1102
  $("conn").title = s.running ? "The selected backend is running" : "The selected backend starts with the next message";
895
1103
  // only the selected backend's availability matters: Codex missing is no news to someone using Claude
@@ -965,7 +1173,9 @@ function connect() {
965
1173
  d.chat.forEach(renderEntry);
966
1174
  applyStatus(d.status);
967
1175
  scrollChat();
1176
+ api("comments").then((r) => r.json()).then(applyComments).catch(() => {});
968
1177
  },
1178
+ comments: (v) => applyComments(v),
969
1179
  chat(e) {
970
1180
  const change = (e.kind === "turn" || e.kind === "edit") && e.turnId;
971
1181
  if (change) state.turns.set(e.turnId, { id: e.turnId, undone: false });
@@ -1199,6 +1409,7 @@ $("provider-switch").addEventListener("close", async () => {
1199
1409
  let deleting = false;
1200
1410
  async function requestDelete(eid) {
1201
1411
  if (deleting || !eid) return;
1412
+ if (quartoWhy()) return toast(quartoWhy());
1202
1413
  if (state.busy) return toast("The assistant is working. Wait until it has finished.");
1203
1414
  deleting = true;
1204
1415
  try {
package/public/index.html CHANGED
@@ -71,8 +71,9 @@
71
71
  title="Drag to resize the notes (double-click: default size)" aria-label="Resize the speaker notes"></div>
72
72
  <button id="notes-toggle" class="notes-head" aria-expanded="true" aria-controls="notes" title="Show or hide the speaker notes">
73
73
  <span class="caret" aria-hidden="true">▾</span> Notes <span id="notes-status" class="notes-status"></span></button>
74
- <textarea id="notes" spellcheck="true" aria-label="Speaker notes of this slide"
75
- placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></textarea>
74
+ <div id="notes" class="notes-box" contenteditable="false" role="textbox" aria-multiline="true" spellcheck="true"
75
+ aria-label="Speaker notes of this slide"
76
+ data-placeholder="Speaker notes for this slide, shown in the speaker view (S) when presenting. ⌘B bold, ⌘I italic, ⌘U underline. Saved when you click elsewhere or press ⌘Enter; Esc undoes your typing."></div>
76
77
  </section>
77
78
  </main>
78
79
 
@@ -90,25 +91,43 @@
90
91
  </span>
91
92
  <span class="head-actions">
92
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>
93
96
  <button id="new-chat" class="small" title="Start a new conversation">New chat</button>
94
97
  </span>
95
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>
96
111
  <div id="chat" class="chat" aria-live="polite">
97
112
  <div class="empty" id="chat-empty">
98
113
  <p><strong>Pin, then ask.</strong></p>
99
114
  <p>Press <kbd>P</kbd> and click elements on the slide. Each click gets a number. Then write what to change, e.g.
100
115
  <em>“1: align with 2, make 3 smaller”</em>.</p>
101
- <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,
102
117
  double-click to change the text. A selected element is what the assistant takes “this” to mean.</p>
103
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
104
119
  or, for a paper or report, take the details from it.</p>
105
- <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.
106
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>
107
124
  </div>
108
125
  </div>
109
126
  <div id="drop-hint" class="drop-hint" hidden><div>Drop figures, audio or PDFs to attach them</div></div>
110
127
  <div id="working" class="working" hidden><span class="spinner"></span> <span id="working-text">The assistant is working…</span></div>
111
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>
112
131
  <div id="pin-tray" class="pin-tray"></div>
113
132
  <div id="attach-tray" class="attach-tray"></div>
114
133
  <textarea id="input" rows="3" placeholder="Ask for a change… refer to pins by number (1, 2, …)"></textarea>
@@ -124,6 +143,7 @@
124
143
  <input type="file" id="file-input" multiple accept="image/*,audio/*,.svg,.pdf,application/pdf" hidden>
125
144
  <span class="spacer"></span>
126
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>
127
147
  <button id="send" class="primary">Send</button>
128
148
  <button id="stop" class="danger" hidden>Stop</button>
129
149
  </div>