pindeck 3.4.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 -736
- 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/lib/agent-tools.mjs +2 -2
- package/lib/deck-list.mjs +5 -3
- package/lib/deck-target.mjs +9 -5
- package/lib/eids.mjs +7 -3
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +9 -0
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +19 -9
- package/public/editor.css +34 -3
- package/public/editor.js +205 -34
- package/public/index.html +21 -2
- package/server.mjs +240 -17
package/lib/review.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/* Review comments (after Sebastian J. Schlecht's review mode for PinDeck).
|
|
2
|
+
|
|
3
|
+
A comment is a note on a slide or one of its elements, kept for later instead of sent to the assistant now.
|
|
4
|
+
Comments live in <deck folder>/.pindeck/<deck>.review.json: a dot folder, so they are never deck files, never
|
|
5
|
+
part of an undo and never edited by the assistant. Each comment keeps what it needs to be found again after
|
|
6
|
+
the deck changes: the slide's id attribute (Quarto's heading anchor), title and position, and the element's
|
|
7
|
+
id, tag and text. A comment whose slide or element cannot be found again is flagged, never silently moved.
|
|
8
|
+
Next to the comments, <deck>.review.md is the review packet: the open comments, for an agent outside PinDeck. */
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import crypto from "node:crypto";
|
|
13
|
+
import { assignEids, parse, findSlides, walk, textOf } from "./eids.mjs";
|
|
14
|
+
import { writeAtomic } from "./deck-files.mjs";
|
|
15
|
+
|
|
16
|
+
const norm = (s) => String(s ?? "").replace(/\s+/g, " ").trim();
|
|
17
|
+
const classes = (n) => (n.attrs?.class?.value || "").split(/\s+/).filter(Boolean);
|
|
18
|
+
const tagOf = (n) => [n.name, ...classes(n)].join(".");
|
|
19
|
+
|
|
20
|
+
/* The deck as comments see it: per slide, its id attribute, title, position in the stacks and its elements by
|
|
21
|
+
id. Ids are assigned in memory (a deck PinDeck has opened has them already; a rendered Quarto deck gets them
|
|
22
|
+
again after each render). */
|
|
23
|
+
export function indexDeck(html) {
|
|
24
|
+
const withIds = assignEids(html).html;
|
|
25
|
+
const tree = parse(withIds);
|
|
26
|
+
const slides = findSlides(tree).map((sec, index) => {
|
|
27
|
+
const stacked = sec.parent?.name === "section";
|
|
28
|
+
const top = stacked ? sec.parent : sec;
|
|
29
|
+
const siblings = (n) => (n.parent?.children || []).filter((c) => c.name === sec.name);
|
|
30
|
+
let head = null;
|
|
31
|
+
walk(sec, (c) => { if (head) return false; if (/^h[1-3]$/.test(c.name)) { head = c; return false; } });
|
|
32
|
+
const elements = new Map();
|
|
33
|
+
walk(sec, (c) => {
|
|
34
|
+
if (c.name === "section" || (c.name === "aside" && classes(c).includes("notes"))) return false;
|
|
35
|
+
const eid = c.attrs?.["data-eid"]?.value;
|
|
36
|
+
if (!eid) return;
|
|
37
|
+
let fragment = false;
|
|
38
|
+
for (let p = c; p && p !== sec; p = p.parent) if (classes(p).includes("fragment")) fragment = true;
|
|
39
|
+
const src = c.name === "img" ? (c.attrs["data-src"] || c.attrs.src)?.value : null;
|
|
40
|
+
elements.set(eid, { eid, tag: tagOf(c), text: norm(src || textOf(withIds, c)), fragment });
|
|
41
|
+
});
|
|
42
|
+
return {
|
|
43
|
+
index, eid: sec.attrs["data-eid"]?.value, id: sec.attrs.id?.value || null,
|
|
44
|
+
title: head ? norm(textOf(withIds, head)) : "",
|
|
45
|
+
h: siblings(top).indexOf(top), v: stacked ? siblings(sec).indexOf(sec) : 0, elements,
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
return { html: withIds, slides };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* A new comment from a pin placed in the editor (bridge.js onClick), or from the slide as a whole (pin null,
|
|
52
|
+
slide { index, eid }). */
|
|
53
|
+
export function commentFromPin(deck, { pin, slide: whole }, text) {
|
|
54
|
+
const ref = pin ? pin.slide : whole;
|
|
55
|
+
const slide = deck.slides[ref?.index];
|
|
56
|
+
if (!slide || slide.eid !== ref.eid) throw new Error("The slide has changed since the pin was placed. Pin it again.");
|
|
57
|
+
const onSlide = !pin || pin.eid === slide.eid;
|
|
58
|
+
const el = onSlide ? null : slide.elements.get(pin.eid);
|
|
59
|
+
if (!onSlide && !el) throw new Error("Element not found in the deck. Pin it again.");
|
|
60
|
+
const r = (v) => Math.round(v * 1e4) / 1e4;
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
return {
|
|
63
|
+
id: "c-" + crypto.randomBytes(4).toString("hex"), created: now, updated: now, status: "open", text: String(text).trim(),
|
|
64
|
+
slide: { id: slide.id, eid: slide.eid, title: slide.title, h: slide.h, v: slide.v },
|
|
65
|
+
target: el ? { eid: el.eid, tag: el.tag, text: el.text, fragment: el.fragment, word: pin.word || null, cell: pin.cell || null }
|
|
66
|
+
: { eid: null, tag: "section", text: "", fragment: false, word: null, cell: null },
|
|
67
|
+
// where the marker sits: on the slide (px), and inside its element (0..1 of its box)
|
|
68
|
+
point: pin?.point ? { x: r(pin.point.x), y: r(pin.point.y) } : null,
|
|
69
|
+
at: pin?.at ? { fx: r(pin.at.fx), fy: r(pin.at.fy) } : null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Where a stored comment is in the deck as it is now.
|
|
74
|
+
status: ok same slide, same element, same text
|
|
75
|
+
moved the element's text was found elsewhere on the slide (ids can be positional)
|
|
76
|
+
changed the element is there but its text differs: possibly already addressed
|
|
77
|
+
missing the slide is there, the element is not
|
|
78
|
+
no-slide neither the slide's id nor its title exists any more
|
|
79
|
+
Only "ok" and "moved" place the marker on the element; the others at the stored point on the slide (if any). */
|
|
80
|
+
export function locate(deck, c) {
|
|
81
|
+
// shortcut: repeated titles resolve to the first; add an h/v tiebreak if decks repeat titles without ids
|
|
82
|
+
const slide = (c.slide.id && deck.slides.find((s) => s.id === c.slide.id)) ||
|
|
83
|
+
(c.slide.eid && deck.slides.find((s) => s.eid === c.slide.eid && (!c.slide.title || s.title === c.slide.title))) ||
|
|
84
|
+
(c.slide.title && deck.slides.find((s) => s.title === c.slide.title));
|
|
85
|
+
if (!slide) return { status: "no-slide", slide: null, eid: null };
|
|
86
|
+
const t = c.target;
|
|
87
|
+
if (!t.eid) return { status: "ok", slide, eid: null };
|
|
88
|
+
const els = [...slide.elements.values()];
|
|
89
|
+
const same = slide.elements.get(t.eid);
|
|
90
|
+
if (same && same.tag === t.tag && same.text === t.text) return { status: "ok", slide, eid: same.eid };
|
|
91
|
+
const moved = t.text && (els.find((e) => e.tag === t.tag && e.text === t.text) || els.find((e) => e.text === t.text));
|
|
92
|
+
if (moved) return { status: "moved", slide, eid: moved.eid };
|
|
93
|
+
if (same && same.tag === t.tag) return { status: "changed", slide, eid: null, now: same.text };
|
|
94
|
+
return { status: "missing", slide, eid: null };
|
|
95
|
+
}
|
|
96
|
+
export const matched = (status) => status === "ok" || status === "moved";
|
|
97
|
+
|
|
98
|
+
/* ---- The comments file ------------------------------------------------------------------------ */
|
|
99
|
+
export function reviewFiles(deckDir, deckFile) {
|
|
100
|
+
const base = path.join(deckDir, ".pindeck", deckFile.replace(/\.html?$/i, ""));
|
|
101
|
+
return { json: base + ".review.json", packet: base + ".review.md" };
|
|
102
|
+
}
|
|
103
|
+
export function loadReview(file) {
|
|
104
|
+
try {
|
|
105
|
+
const r = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
106
|
+
if (!r || !Array.isArray(r.comments)) throw new Error(`${file} is not a PinDeck comments file.`);
|
|
107
|
+
// edited by hand or by another agent: a comment without what locate() needs would crash the server later
|
|
108
|
+
const bad = r.comments.findIndex((c) => typeof c?.id !== "string" || typeof c.text !== "string" || !c.slide || typeof c.slide !== "object" || !c.target || typeof c.target !== "object");
|
|
109
|
+
if (bad >= 0) throw new Error(`comment ${bad + 1} lacks its id, text, slide or target`);
|
|
110
|
+
const badReplies = r.comments.findIndex((c) => c.replies != null && !(Array.isArray(c.replies) && c.replies.every((x) => typeof x?.text === "string")));
|
|
111
|
+
if (badReplies >= 0) throw new Error(`comment ${badReplies + 1} has replies that are not a list of { by, text }`);
|
|
112
|
+
return r;
|
|
113
|
+
} catch (e) { if (e.code === "ENOENT") return { pindeckReview: 1, deck: {}, comments: [] }; throw e; }
|
|
114
|
+
}
|
|
115
|
+
export function saveReview(file, review) {
|
|
116
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
117
|
+
writeAtomic(file, JSON.stringify(review, null, 2) + "\n"); // a crash never leaves half a file
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ---- The review packet ------------------------------------------------------------------------------ */
|
|
121
|
+
const pandocId = (s) => norm(s.replace(/\{[^}]*\}\s*$/, "")).toLowerCase()
|
|
122
|
+
.replace(/[^\p{L}\p{N}\s_.-]/gu, "").replace(/^[^\p{L}]+/u, "").replace(/\s+/g, "-");
|
|
123
|
+
|
|
124
|
+
/* 1-based line of the slide's heading in a Quarto/Markdown source, or null. */
|
|
125
|
+
export function sourceLine(srcLines, slide) {
|
|
126
|
+
if (!srcLines || !slide) return null;
|
|
127
|
+
const i = srcLines.findIndex((l) => {
|
|
128
|
+
const m = /^#{1,3}\s+(.*)$/.exec(l);
|
|
129
|
+
if (!m) return false;
|
|
130
|
+
const explicit = /\{[^}]*#([\w:.-]+)/.exec(m[1]);
|
|
131
|
+
return explicit ? explicit[1] === slide.id : pandocId(m[1]) === slide.id || norm(m[1]) === slide.title;
|
|
132
|
+
});
|
|
133
|
+
return i < 0 ? null : i + 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const STATUS = {
|
|
137
|
+
ok: "ok", moved: "ok (element found again by its text)",
|
|
138
|
+
changed: "UNMATCHED: the element's text has changed since the comment (maybe already addressed)",
|
|
139
|
+
missing: "UNMATCHED: the element is no longer on this slide",
|
|
140
|
+
"no-slide": "UNMATCHED: the slide is no longer in the deck",
|
|
141
|
+
};
|
|
142
|
+
const clip = (s, n = 240) => (s.length > n ? s.slice(0, n - 1) + "…" : s);
|
|
143
|
+
const slideName = (s) => `slide ${s.h + 1}${s.v ? "." + s.v : ""} “${s.title || "(untitled)"}”`;
|
|
144
|
+
|
|
145
|
+
/* The open comments in deck order, each with where it is now and its number in the editor's list (C1, C2, …) */
|
|
146
|
+
export function openComments(review, deck) {
|
|
147
|
+
const n = new Map(review.comments.map((c, i) => [c.id, i + 1]));
|
|
148
|
+
const key = (x) => (x.loc.slide ? x.loc.slide.index : 1e6) * 1e5 + (x.c.point?.y ?? 0);
|
|
149
|
+
return review.comments.filter((c) => c.status !== "resolved").map((c) => ({ c, n: n.get(c.id), loc: locate(deck, c) }))
|
|
150
|
+
.sort((a, b) => key(a) - key(b));
|
|
151
|
+
}
|
|
152
|
+
const who = (by) => (by === "you" ? "Reviewer" : by === "assistant" ? "Assistant" : by ? `${by[0].toUpperCase()}${by.slice(1)}` : "Agent");
|
|
153
|
+
function items(list, lines) {
|
|
154
|
+
return list.flatMap(({ c, n, loc }) => {
|
|
155
|
+
const s = loc.slide, t = c.target;
|
|
156
|
+
const line = sourceLine(lines, s || { id: c.slide.id, title: c.slide.title });
|
|
157
|
+
return [`## C${n}. ${s ? slideName(s) : "was " + slideName(c.slide)}`, "",
|
|
158
|
+
`> ${c.text.replace(/\n/g, "\n> ")}`, "",
|
|
159
|
+
`- Slide: ${c.slide.id ? `\`#${c.slide.id}\`` : `\`${s?.eid || c.slide.eid}\``}${line ? `, source line ${line}` : ""} — ${STATUS[loc.status]}`,
|
|
160
|
+
t.eid ? `- Element: \`${t.tag}\`${matched(loc.status) ? ` (data-eid \`${loc.eid}\`)` : ""}${t.fragment ? ", a build step" : ""}: “${clip(t.text)}”`
|
|
161
|
+
: "- Element: the slide as a whole",
|
|
162
|
+
...(t.word ? [`- Clicked word: “${t.word}”`] : []),
|
|
163
|
+
...(t.cell ? [`- Table cell: row ${t.cell.row + 1}, column ${t.cell.col + 1}: “${clip(t.cell.text || "", 80)}”`] : []),
|
|
164
|
+
...(loc.status === "changed" ? [`- Text now: “${clip(loc.now)}”`] : []),
|
|
165
|
+
...(c.replies?.length ? ["- Thread, oldest first:", ...c.replies.map((r) => ` - ${who(r.by)}: ${r.text.replace(/\n/g, " ")}`)] : []),
|
|
166
|
+
`- Comment id: \`${c.id}\``, ""];
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* For an agent outside PinDeck (Claude Code in an editor, say): written next to the comments file */
|
|
171
|
+
export function packet(review, deck, { deckFile = "the deck", source = null, srcText = null, commentsFile = "?" } = {}) {
|
|
172
|
+
const open = openComments(review, deck);
|
|
173
|
+
const stale = open.filter((x) => !matched(x.loc.status));
|
|
174
|
+
const resolved = review.comments.length - open.length;
|
|
175
|
+
return [
|
|
176
|
+
`# Slide review: ${deckFile}`, "",
|
|
177
|
+
`- Deck: \`${deckFile}\`${source ? `, source \`${source}\`` : ""}`,
|
|
178
|
+
`- Comments: \`${commentsFile}\``,
|
|
179
|
+
`- ${open.length} open comment(s), ${stale.length} unmatched; ${resolved} resolved (not listed).`, "",
|
|
180
|
+
"These are reviewer comments on the slides. They are not speaker notes: do not copy them into the deck.",
|
|
181
|
+
source ? `Make each change in the source (\`${source}\`, its stylesheets or figure scripts), never in the rendered HTML, ` +
|
|
182
|
+
"which the next render overwrites. Render, then" : "Make each change, then",
|
|
183
|
+
"set the comment's `status` to `\"resolved\"` in the comments file. If you cannot do one, or it is unclear, leave it open",
|
|
184
|
+
"and add `{ \"by\": \"agent\", \"text\": \"<why>\" }` to its `replies` list, where the reviewer can answer. Read a comment's",
|
|
185
|
+
"thread before acting on it. UNMATCHED comments point at slides or text that changed after the comment was made:",
|
|
186
|
+
"check whether they still apply before acting on them.", "",
|
|
187
|
+
...items(open, srcText?.split("\n")),
|
|
188
|
+
].join("\n");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* For PinDeck's own assistant: the open comments as one request, put in the message box for the user to send */
|
|
192
|
+
export function request(review, deck, { source = null, srcText = null } = {}) {
|
|
193
|
+
const open = openComments(review, deck);
|
|
194
|
+
if (!open.length) return "";
|
|
195
|
+
return [`Please address these ${open.length} review comment(s), one by one. They are notes for you, not slide text or speaker notes.`,
|
|
196
|
+
"Mark each one you have addressed as resolved with PinDeck's resolve command and its comment id. If one is unclear or no",
|
|
197
|
+
"longer applies, leave it open and say why with PinDeck's reply command instead of guessing. Where a comment has a",
|
|
198
|
+
"thread, the reviewer's latest answer is what to act on.", "",
|
|
199
|
+
...items(open, source ? srcText?.split("\n") : null)].join("\n").trim();
|
|
200
|
+
}
|
package/lib/servers.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
`pindeck login` and cli.mjs.
|
|
3
3
|
|
|
4
4
|
Each PinDeck process (a front, a deck server on its own, or one behind a front) keeps a file of its own in
|
|
5
|
-
~/.pindeck-
|
|
5
|
+
~/.pindeck-v4/servers/<pid>.json (0600, in a 0700 folder) with its address and token. Several fronts and
|
|
6
6
|
servers can run side by side (another --port, another PINDECK_HOME user, a server started on its own) and
|
|
7
7
|
none overwrites another's file; a file whose process is gone is removed when it is read. */
|
|
8
8
|
|
package/lib/system-prompt.md
CHANGED
|
@@ -81,6 +81,15 @@ overlapping elements, clipped text); add --issues to print only the issues. Fix
|
|
|
81
81
|
{{CLI}} shot <slide-eid or number>...
|
|
82
82
|
only when you need a visual judgement the numbers cannot give (balance, figure content, colour)
|
|
83
83
|
or the user asks; it prints the path of a PNG, which you then Read.
|
|
84
|
+
## Review comments
|
|
85
|
+
The user can keep comments on slides (ids c-…, numbered C1, C2, … in the editor) and ask you to address them.
|
|
86
|
+
They are notes for you, not slide text or speaker notes. When you have addressed one, mark it resolved:
|
|
87
|
+
{{CLI}} resolve <comment id> <one line on what you did>
|
|
88
|
+
Leave a comment open when you could not do it, or it was unclear, and say why on the comment itself, where the
|
|
89
|
+
user can answer you:
|
|
90
|
+
{{CLI}} reply <comment id> <why not, or what you need to know>
|
|
91
|
+
A comment's thread (your replies and the user's) comes with it; read the user's latest answer before acting.
|
|
92
|
+
|
|
84
93
|
If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build steps complete) or
|
|
85
94
|
`{{CLI}} pdf presentation` (one page per build step); it prints the path of the PDF.
|
|
86
95
|
|
package/lib/usage.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/* pindeck --help, and the hint after a bad argument: the front (front.mjs) and a deck server (server.mjs)
|
|
2
2
|
take the same arguments. */
|
|
3
|
-
export const USAGE = `Usage: pindeck [deck-folder | deck-file.html] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
|
|
3
|
+
export const USAGE = `Usage: pindeck [deck-folder | deck-file.html | quarto-deck.qmd] [--port 5180] [--provider claude|codex] [--model <name>] [--asset-root <folder>]...
|
|
4
4
|
pindeck login [deck-folder | deck-file.html]
|
|
5
5
|
|
|
6
6
|
Opens a reveal.js deck in PinDeck: a deck file such as talk.html, or a folder (default: the current
|
|
7
7
|
folder), which then opens its index.html; a folder without one opens a list of its decks. A deck made
|
|
8
8
|
otherwise names its slides in its <head> with <meta name="pindeck:slides" content="<CSS selector>"> and
|
|
9
|
-
<meta name="pindeck:size" content="1440x1080">.
|
|
9
|
+
<meta name="pindeck:size" content="1440x1080">. A Quarto deck (talk.qmd) is rendered by PinDeck, and the
|
|
10
|
+
assistant edits its source.
|
|
10
11
|
In the editor, the deck's name opens the other decks in the deck's folder and the asset roots (and one
|
|
11
12
|
folder down), each with its own chat and undo, on the same port and login.
|
|
12
13
|
login prints a new one-time login link for the running PinDeck (with a deck: one that opens that deck).
|
|
13
14
|
--asset-root lets the deck use files outside its folder: a folder that contains the deck (paths such as
|
|
14
15
|
../shared/style.css then work), or one that symlinks in the deck may point to. Repeatable; also
|
|
15
|
-
"assetRoots" in ~/.pindeck-
|
|
16
|
+
"assetRoots" in ~/.pindeck-v4/config.json.`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pindeck",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-beta.1",
|
|
4
4
|
"description": "PinDeck: an editor for HTML slide decks on macOS, and on Linux through an SSH tunnel. Pin elements, describe the change, and Claude Code or OpenAI Codex edits the slides.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"slides",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"lib/",
|
|
33
33
|
"public/",
|
|
34
34
|
"README.md",
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"docs/*.md",
|
|
37
|
+
"!docs/v4-plan.md",
|
|
35
38
|
"LICENSE"
|
|
36
39
|
],
|
|
37
40
|
"scripts": {
|
package/public/bridge.js
CHANGED
|
@@ -42,6 +42,9 @@
|
|
|
42
42
|
background:#ED8C01; color:#fff; font:700 calc(14px*var(--pindeck-u,1))/calc(26px*var(--pindeck-u,1)) system-ui, sans-serif; text-align:center;
|
|
43
43
|
box-shadow:0 0 0 calc(2px*var(--pindeck-u,1)) #fff, 0 calc(2px*var(--pindeck-u,1)) calc(6px*var(--pindeck-u,1)) rgba(0,0,0,.45);
|
|
44
44
|
cursor:pointer; pointer-events:auto; }
|
|
45
|
+
.__pin.__comment { background:#1a73e8; border-radius:calc(6px*var(--pindeck-u,1)); width:auto; min-width:calc(26px*var(--pindeck-u,1));
|
|
46
|
+
padding:0 calc(5px*var(--pindeck-u,1)); }
|
|
47
|
+
.__pin.__stale { background:#8a8f98; }
|
|
45
48
|
.__hover { position:absolute; z-index:999; pointer-events:none; outline:calc(2px*var(--pindeck-u,1)) dashed #ED8C01;
|
|
46
49
|
outline-offset:calc(2px*var(--pindeck-u,1)); background:rgba(237,140,1,.06); }
|
|
47
50
|
.__hover span { position:absolute; left:calc(-2px*var(--pindeck-u,1)); background:#ED8C01; color:#fff; white-space:nowrap;
|
|
@@ -102,19 +105,22 @@
|
|
|
102
105
|
const byEid = (eid, root = document) => root.querySelector(`[data-eid="${CSS.escape(eid)}"]`);
|
|
103
106
|
|
|
104
107
|
/* Visible text of an element, without KaTeX's hidden MathML copy. */
|
|
108
|
+
const MATH_TEX = 'script[type^="math/tex"]'; // MathJax 2 (Quarto) keeps the TeX here; its rendering is skipped
|
|
105
109
|
function textOf(el, ownOnly) {
|
|
106
110
|
if (el.tagName === "IMG") return el.getAttribute("alt") || (el.getAttribute("src") || "").split("/").pop();
|
|
107
111
|
let out = "";
|
|
108
112
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
|
109
113
|
acceptNode(n) {
|
|
110
114
|
const p = n.parentElement;
|
|
111
|
-
if (
|
|
115
|
+
if (p?.matches(MATH_TEX)) return NodeFilter.FILTER_ACCEPT;
|
|
116
|
+
if (!p || p.closest(".katex-mathml, .MathJax, .MathJax_Display, .MathJax_Preview, .MathJax_CHTML, .MathJax_SVG, mjx-container, .__pin, .__hover, .__flash, .__sel, .__guide, .__edit-hint, .__hidden-banner, aside.notes, script, style")) return NodeFilter.FILTER_REJECT;
|
|
112
117
|
if (ownOnly) { const owner = p.closest("[data-eid]"); if (owner !== el) return NodeFilter.FILTER_REJECT; }
|
|
113
118
|
return NodeFilter.FILTER_ACCEPT;
|
|
114
119
|
},
|
|
115
120
|
});
|
|
116
121
|
while (walker.nextNode()) {
|
|
117
122
|
const n = walker.currentNode;
|
|
123
|
+
if (n.parentElement.matches(MATH_TEX)) { out += `\\(${n.nodeValue.trim()}\\) `; continue; }
|
|
118
124
|
out += n.nodeValue + (n.parentElement.closest(".katex") ? "" : " "); // KaTeX splits math into one span per glyph
|
|
119
125
|
}
|
|
120
126
|
return out.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
|
|
@@ -239,7 +245,8 @@
|
|
|
239
245
|
const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
|
|
240
246
|
|
|
241
247
|
/* ---- Pins and hover ---------------------------------------------------------------------- */
|
|
242
|
-
|
|
248
|
+
// mode: "select" (drag, resize) or "pin"; lockReason: why hand edits are off for good (a Quarto deck)
|
|
249
|
+
let mode = "select", pins = [], editable = true, lockReason = null;
|
|
243
250
|
const tagOf = (el) => el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !c.startsWith("__")).join(".") : "");
|
|
244
251
|
function wordAt(x, y, el) {
|
|
245
252
|
let node, offset;
|
|
@@ -250,7 +257,7 @@
|
|
|
250
257
|
const r = document.caretRangeFromPoint(x, y);
|
|
251
258
|
if (r) { node = r.startContainer; offset = r.startOffset; }
|
|
252
259
|
}
|
|
253
|
-
if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml")) return null;
|
|
260
|
+
if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml, .MathJax, mjx-container")) return null;
|
|
254
261
|
const s = node.nodeValue;
|
|
255
262
|
const isW = (c) => /[\p{L}\p{N}_\-.,%×]/u.test(c);
|
|
256
263
|
let a = offset, b = offset;
|
|
@@ -276,6 +283,8 @@
|
|
|
276
283
|
function onClick(e) {
|
|
277
284
|
if (MODE !== "main") return;
|
|
278
285
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
|
286
|
+
const comment = e.target.closest?.(".__comment"); // a review comment's marker opens it, in either mode
|
|
287
|
+
if (comment) { host()?.openComment(comment.dataset.comment); return; }
|
|
279
288
|
if (mode !== "pin") return;
|
|
280
289
|
const marker = e.target.closest?.(".__pin");
|
|
281
290
|
if (marker) { host()?.removePin(+marker.dataset.n); return; }
|
|
@@ -329,10 +338,11 @@
|
|
|
329
338
|
x = b.x + p.at.fx * b.w; y = b.y + p.at.fy * b.h;
|
|
330
339
|
}
|
|
331
340
|
const m = document.createElement("div");
|
|
332
|
-
m.className = "__pin";
|
|
341
|
+
m.className = "__pin" + (p.comment ? " __comment" : "") + (p.stale ? " __stale" : "");
|
|
333
342
|
m.dataset.n = p.n;
|
|
343
|
+
if (p.comment) m.dataset.comment = p.comment;
|
|
334
344
|
m.textContent = p.n;
|
|
335
|
-
m.title = "Click to remove pin " + p.n;
|
|
345
|
+
m.title = p.title || "Click to remove pin " + p.n;
|
|
336
346
|
Object.assign(m.style, { left: x + "px", top: y + "px" });
|
|
337
347
|
sec.appendChild(m);
|
|
338
348
|
}
|
|
@@ -487,7 +497,7 @@
|
|
|
487
497
|
}
|
|
488
498
|
if (!sel || sel.el !== t.el) select(t.el, t.sec);
|
|
489
499
|
}
|
|
490
|
-
if (!editable) { if (handle) host()?.toast("Claude is working. Moving and resizing resume when it has finished."); return; }
|
|
500
|
+
if (!editable) { if (handle) host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished."); return; }
|
|
491
501
|
const el = sel.el, cs = getComputedStyle(el);
|
|
492
502
|
const { s } = frame(sel.sec);
|
|
493
503
|
const [tx, ty] = translateOf(el);
|
|
@@ -576,7 +586,7 @@
|
|
|
576
586
|
}
|
|
577
587
|
function nudge(dx, dy) {
|
|
578
588
|
if (!sel) return;
|
|
579
|
-
if (!editable) return host()?.toast("Claude is working. Moving and resizing resume when it has finished.");
|
|
589
|
+
if (!editable) return host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished.");
|
|
580
590
|
const el = sel.el;
|
|
581
591
|
const [tx, ty] = translateOf(el);
|
|
582
592
|
el.style.translate = fmtTranslate(tx + dx, ty + dy);
|
|
@@ -641,7 +651,7 @@
|
|
|
641
651
|
}
|
|
642
652
|
|
|
643
653
|
async function startEdit(t, x, y, opts = {}) {
|
|
644
|
-
if (!editable) return host()?.toast("Claude is working. Editing resumes when it has finished.");
|
|
654
|
+
if (!editable) return host()?.toast(lockReason || "Claude is working. Editing resumes when it has finished.");
|
|
645
655
|
if (editing) await finishEdit(true);
|
|
646
656
|
const src = opts.src ?? await host()?.textSource(t.eid, t.path);
|
|
647
657
|
if (src == null || !t.el.isConnected) return;
|
|
@@ -1087,7 +1097,7 @@
|
|
|
1087
1097
|
isEditing: () => !!editing,
|
|
1088
1098
|
deselect() { select(null, null); },
|
|
1089
1099
|
hasSelection: () => !!sel,
|
|
1090
|
-
setEditable(on) { editable = !!on; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
|
|
1100
|
+
setEditable(on, why = null) { editable = !!on; lockReason = why; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
|
|
1091
1101
|
renderPins,
|
|
1092
1102
|
async measure(index) {
|
|
1093
1103
|
goto(index);
|
package/public/editor.css
CHANGED
|
@@ -173,7 +173,7 @@ code { font: 12.5px ui-monospace, SFMono-Regular, Menlo, monospace; background:
|
|
|
173
173
|
/* the splitter: the panel's top edge, dragged up or down like the notes divider in PowerPoint or Keynote */
|
|
174
174
|
.notes-split { position: absolute; left: 0; right: 0; top: -4px; height: 8px; z-index: 2; cursor: row-resize; touch-action: none; }
|
|
175
175
|
.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,
|
|
176
|
+
.notes-split:hover::after, .notes-split:focus-visible::after, .notes-split.dragging::after { background: var(--orange); }
|
|
177
177
|
.notes-split:focus-visible { outline: none; }
|
|
178
178
|
.notes-panel.collapsed .notes-split { display: none; }
|
|
179
179
|
body.resizing-notes { cursor: row-resize; user-select: none; }
|
|
@@ -244,16 +244,47 @@ body.resizing-notes .stage iframe { pointer-events: none; } /* keep the drag w
|
|
|
244
244
|
.spinner { width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--orange); border-radius: 50%;
|
|
245
245
|
animation: spin .8s linear infinite; }
|
|
246
246
|
@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; }
|
|
247
|
+
.composer { position: relative; border-top: 1px solid var(--line); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
248
248
|
.pin-tray:empty { display: none; }
|
|
249
249
|
.pin-tray { max-height: 96px; overflow-y: auto; }
|
|
250
250
|
#input { width: 100%; resize: none; font: inherit; border: 1px solid var(--line); border-radius: var(--radius);
|
|
251
|
-
padding: 8px 10px;
|
|
251
|
+
padding: 8px 10px; outline: none; }
|
|
252
252
|
#input:focus { border-color: var(--orange); box-shadow: 0 0 0 3px rgba(237,140,1,.15); }
|
|
253
253
|
.composer-row { display: flex; align-items: center; gap: 8px; }
|
|
254
254
|
.composer-row .spacer { flex: 1; }
|
|
255
255
|
.check { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--muted); cursor: pointer; }
|
|
256
256
|
|
|
257
|
+
/* ---- A Quarto deck: rendered from its source, so no hand edits ---- */
|
|
258
|
+
body.quarto #add-slide-btn, body.quarto .slide-list .eye, body.quarto .hand-only { display: none; }
|
|
259
|
+
body:not(.quarto) .quarto-only { display: none; }
|
|
260
|
+
body.quarto #notes:empty::before { content: "Speaker notes come from ::: {.notes} in the Quarto source."; }
|
|
261
|
+
|
|
262
|
+
/* ---- Review comments ---- */
|
|
263
|
+
.count:empty { display: none; }
|
|
264
|
+
.count { background: #1a73e8; color: #fff; border-radius: 8px; padding: 0 5px; font-size: 11px; font-weight: 600; }
|
|
265
|
+
.comments-panel { border-bottom: 1px solid var(--line); max-height: 45%; overflow-y: auto; padding: 8px 12px 10px; background: var(--bg); }
|
|
266
|
+
.comments-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
|
267
|
+
.comments-head .spacer { flex: 1; }
|
|
268
|
+
.comments-empty, .comments-file { font-size: 12.5px; margin: 4px 0; color: var(--muted); }
|
|
269
|
+
.comment-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
270
|
+
.cmt { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 6px 8px; cursor: pointer; font-size: 13px; }
|
|
271
|
+
.cmt:hover, .cmt.flash { border-color: #1a73e8; }
|
|
272
|
+
.cmt .top { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 12px; }
|
|
273
|
+
.cmt .n { background: #1a73e8; color: #fff; border-radius: 4px; padding: 0 5px; font-weight: 700; font-size: 11px; }
|
|
274
|
+
.cmt.stale .n { background: #8a8f98; }
|
|
275
|
+
.cmt .top .spacer { flex: 1; }
|
|
276
|
+
.cmt .text { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
277
|
+
.cmt .edit { display: block; width: 100%; box-sizing: border-box; margin: 4px 0 0; font: inherit; resize: vertical; }
|
|
278
|
+
.cmt .reply { margin: 4px 0 0 10px; padding-left: 6px; border-left: 2px solid var(--line); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
279
|
+
.cmt .reply.mine { border-left-color: #1a73e8; }
|
|
280
|
+
.cmt.resolved .reply { text-decoration: none; }
|
|
281
|
+
.cmt .edit-actions { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 12px; }
|
|
282
|
+
.cmt .flag { margin: 4px 0 0; color: var(--danger); font-size: 12px; }
|
|
283
|
+
.cmt.resolved { opacity: .6; }
|
|
284
|
+
.cmt.resolved .text { text-decoration: line-through; }
|
|
285
|
+
.cmt .done { margin: 4px 0 0; color: var(--ok); font-size: 12px; }
|
|
286
|
+
.cmt.resolved .done { opacity: 1; }
|
|
287
|
+
|
|
257
288
|
/* ---- Delete confirmation ---- */
|
|
258
289
|
.delete-dialog { width: min(460px, 92vw); padding: 0; border: 1px solid var(--line); border-radius: 12px;
|
|
259
290
|
box-shadow: 0 20px 60px rgba(0,0,0,.25); }
|