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/CHANGELOG.md +121 -0
- package/README.md +33 -724
- package/cli.mjs +8 -1
- package/docs/configuration.md +51 -0
- package/docs/how-it-works.md +56 -0
- package/docs/linux-server.md +36 -0
- package/docs/preparing-decks.md +88 -0
- package/docs/using.md +45 -0
- package/front.mjs +3 -2
- package/lib/agent-tools.mjs +2 -2
- package/lib/capabilities.mjs +13 -0
- package/lib/claude.mjs +1 -1
- package/lib/deck-list.mjs +5 -3
- package/lib/deck-target.mjs +9 -5
- package/lib/eids.mjs +8 -4
- package/lib/login.mjs +11 -2
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +11 -2
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +19 -9
- package/public/editor.css +41 -7
- package/public/editor.js +262 -51
- package/public/index.html +24 -4
- package/server.mjs +258 -31
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
|
|
125
|
+
applyEditable(bridge);
|
|
126
126
|
refreshPinsFromSlides();
|
|
127
|
-
bridge.renderPins(
|
|
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">
|
|
369
|
-
or on ⌘Enter; Esc puts back what the file says.
|
|
370
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
390
|
-
notesBox.value = "";
|
|
391
|
-
notesBox.disabled = true;
|
|
418
|
+
showNotes(null, null);
|
|
392
419
|
return notesStatus("Could not load");
|
|
393
420
|
}
|
|
394
|
-
|
|
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:
|
|
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 =
|
|
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") {
|
|
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:
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
<
|
|
75
|
-
|
|
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>
|