pindeck 3.4.0 → 4.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +127 -0
- package/README.md +37 -734
- package/cli.mjs +11 -2
- 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 +13 -3
- package/lib/review.mjs +200 -0
- package/lib/servers.mjs +1 -1
- package/lib/system-prompt.md +13 -2
- package/lib/usage.mjs +4 -3
- package/package.json +4 -1
- package/public/bridge.js +94 -51
- package/public/editor.css +36 -3
- package/public/editor.js +223 -49
- package/public/index.html +22 -3
- package/server.mjs +269 -34
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
|
@@ -69,7 +69,8 @@ this path to cli.mjs even if another one appears earlier in the conversation, ea
|
|
|
69
69
|
convert or ls. Such commands are refused (or need an approval nobody can give) and are not run. A
|
|
70
70
|
refused command did nothing: do not retry it as it is, do it another way.
|
|
71
71
|
- To change files, use Edit, even for large changes: several Edit calls in a row are fine.
|
|
72
|
-
- To look at files, use Read, Grep and Glob.
|
|
72
|
+
- To look at files, use Read, Grep and Glob. A slide can be one very long line: to see one element of
|
|
73
|
+
it, run `{{CLI}} element <data-eid>...`, which prints that element's HTML exactly as written.
|
|
73
74
|
- To check several slides, name them all: `{{CLI}} layout 9 10 11 --issues`.
|
|
74
75
|
|
|
75
76
|
## Checking your work
|
|
@@ -81,6 +82,15 @@ overlapping elements, clipped text); add --issues to print only the issues. Fix
|
|
|
81
82
|
{{CLI}} shot <slide-eid or number>...
|
|
82
83
|
only when you need a visual judgement the numbers cannot give (balance, figure content, colour)
|
|
83
84
|
or the user asks; it prints the path of a PNG, which you then Read.
|
|
85
|
+
## Review comments
|
|
86
|
+
The user can keep comments on slides (ids c-…, numbered C1, C2, … in the editor) and ask you to address them.
|
|
87
|
+
They are notes for you, not slide text or speaker notes. When you have addressed one, mark it resolved:
|
|
88
|
+
{{CLI}} resolve <comment id> <one line on what you did>
|
|
89
|
+
Leave a comment open when you could not do it, or it was unclear, and say why on the comment itself, where the
|
|
90
|
+
user can answer you:
|
|
91
|
+
{{CLI}} reply <comment id> <why not, or what you need to know>
|
|
92
|
+
A comment's thread (your replies and the user's) comes with it; read the user's latest answer before acting.
|
|
93
|
+
|
|
84
94
|
If the user asks for a PDF, run `{{CLI}} pdf handout` (one page per slide, build steps complete) or
|
|
85
95
|
`{{CLI}} pdf presentation` (one page per build step); it prints the path of the PDF.
|
|
86
96
|
|
|
@@ -91,7 +101,8 @@ inline styles on the element in {{DECK}}: `translate: Xpx Ypx` for moves, `width
|
|
|
91
101
|
keep them unless asked. If the user asks to clean up, move them into proper CSS rules.
|
|
92
102
|
The user can also edit text in place (double-click); you get a note with the new text. Keep their
|
|
93
103
|
wording unless asked to change it.
|
|
94
|
-
A selected element is listed in <editor-context>; "this" or "it" usually means that element.
|
|
104
|
+
A selected element is listed in <editor-context>; "this" or "it" usually means that element. When
|
|
105
|
+
several are selected, "these" or "them" means all of them.
|
|
95
106
|
|
|
96
107
|
## Replies
|
|
97
108
|
Replies appear in a narrow sidebar. Keep them short: 1 to 4 sentences saying what you changed,
|
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.1.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;
|
|
@@ -64,7 +67,8 @@
|
|
|
64
67
|
.__sel i[data-h="ne"] { left:100%; top:0; cursor:nesw-resize; } .__sel i[data-h="e"] { left:100%; top:50%; cursor:ew-resize; }
|
|
65
68
|
.__sel i[data-h="se"] { left:100%; top:100%; cursor:nwse-resize; } .__sel i[data-h="s"] { left:50%; top:100%; cursor:ns-resize; }
|
|
66
69
|
.__sel i[data-h="sw"] { left:0; top:100%; cursor:nesw-resize; } .__sel i[data-h="w"] { left:0; top:50%; cursor:ew-resize; }
|
|
67
|
-
.__sel.readonly i { display:none; }
|
|
70
|
+
.__sel.readonly i, .__sel.multi i { display:none; }
|
|
71
|
+
html.__selmode ${SLIDE} .__selected.__member { outline:calc(1px*var(--pindeck-u,1)) dashed #1a73e8; outline-offset:0; }
|
|
68
72
|
.__sel-label { position:absolute; left:calc(-1.5px*var(--pindeck-u,1)); white-space:nowrap; background:#1a73e8; color:#fff;
|
|
69
73
|
font:600 calc(11px*var(--pindeck-u,1))/calc(17px*var(--pindeck-u,1)) system-ui, sans-serif; padding:0 calc(5px*var(--pindeck-u,1));
|
|
70
74
|
border-radius:calc(3px*var(--pindeck-u,1)); font-variant-numeric:tabular-nums; }
|
|
@@ -102,19 +106,22 @@
|
|
|
102
106
|
const byEid = (eid, root = document) => root.querySelector(`[data-eid="${CSS.escape(eid)}"]`);
|
|
103
107
|
|
|
104
108
|
/* Visible text of an element, without KaTeX's hidden MathML copy. */
|
|
109
|
+
const MATH_TEX = 'script[type^="math/tex"]'; // MathJax 2 (Quarto) keeps the TeX here; its rendering is skipped
|
|
105
110
|
function textOf(el, ownOnly) {
|
|
106
111
|
if (el.tagName === "IMG") return el.getAttribute("alt") || (el.getAttribute("src") || "").split("/").pop();
|
|
107
112
|
let out = "";
|
|
108
113
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
|
|
109
114
|
acceptNode(n) {
|
|
110
115
|
const p = n.parentElement;
|
|
111
|
-
if (
|
|
116
|
+
if (p?.matches(MATH_TEX)) return NodeFilter.FILTER_ACCEPT;
|
|
117
|
+
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
118
|
if (ownOnly) { const owner = p.closest("[data-eid]"); if (owner !== el) return NodeFilter.FILTER_REJECT; }
|
|
113
119
|
return NodeFilter.FILTER_ACCEPT;
|
|
114
120
|
},
|
|
115
121
|
});
|
|
116
122
|
while (walker.nextNode()) {
|
|
117
123
|
const n = walker.currentNode;
|
|
124
|
+
if (n.parentElement.matches(MATH_TEX)) { out += `\\(${n.nodeValue.trim()}\\) `; continue; }
|
|
118
125
|
out += n.nodeValue + (n.parentElement.closest(".katex") ? "" : " "); // KaTeX splits math into one span per glyph
|
|
119
126
|
}
|
|
120
127
|
return out.replace(/\u200b/g, "").replace(/\s+/g, " ").trim();
|
|
@@ -239,7 +246,8 @@
|
|
|
239
246
|
const presentHash = (index) => deck.hash(index); // the slide's address in the deck opened on its own
|
|
240
247
|
|
|
241
248
|
/* ---- Pins and hover ---------------------------------------------------------------------- */
|
|
242
|
-
|
|
249
|
+
// mode: "select" (drag, resize) or "pin"; lockReason: why hand edits are off for good (a Quarto deck)
|
|
250
|
+
let mode = "select", pins = [], editable = true, lockReason = null;
|
|
243
251
|
const tagOf = (el) => el.tagName.toLowerCase() + (el.classList.length ? "." + [...el.classList].filter((c) => !c.startsWith("__")).join(".") : "");
|
|
244
252
|
function wordAt(x, y, el) {
|
|
245
253
|
let node, offset;
|
|
@@ -250,7 +258,7 @@
|
|
|
250
258
|
const r = document.caretRangeFromPoint(x, y);
|
|
251
259
|
if (r) { node = r.startContainer; offset = r.startOffset; }
|
|
252
260
|
}
|
|
253
|
-
if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml")) return null;
|
|
261
|
+
if (!node || node.nodeType !== 3 || !el.contains(node) || node.parentElement.closest(".katex-mathml, .MathJax, mjx-container")) return null;
|
|
254
262
|
const s = node.nodeValue;
|
|
255
263
|
const isW = (c) => /[\p{L}\p{N}_\-.,%×]/u.test(c);
|
|
256
264
|
let a = offset, b = offset;
|
|
@@ -276,6 +284,8 @@
|
|
|
276
284
|
function onClick(e) {
|
|
277
285
|
if (MODE !== "main") return;
|
|
278
286
|
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation();
|
|
287
|
+
const comment = e.target.closest?.(".__comment"); // a review comment's marker opens it, in either mode
|
|
288
|
+
if (comment) { host()?.openComment(comment.dataset.comment); return; }
|
|
279
289
|
if (mode !== "pin") return;
|
|
280
290
|
const marker = e.target.closest?.(".__pin");
|
|
281
291
|
if (marker) { host()?.removePin(+marker.dataset.n); return; }
|
|
@@ -329,10 +339,11 @@
|
|
|
329
339
|
x = b.x + p.at.fx * b.w; y = b.y + p.at.fy * b.h;
|
|
330
340
|
}
|
|
331
341
|
const m = document.createElement("div");
|
|
332
|
-
m.className = "__pin";
|
|
342
|
+
m.className = "__pin" + (p.comment ? " __comment" : "") + (p.stale ? " __stale" : "");
|
|
333
343
|
m.dataset.n = p.n;
|
|
344
|
+
if (p.comment) m.dataset.comment = p.comment;
|
|
334
345
|
m.textContent = p.n;
|
|
335
|
-
m.title = "Click to remove pin " + p.n;
|
|
346
|
+
m.title = p.title || "Click to remove pin " + p.n;
|
|
336
347
|
Object.assign(m.style, { left: x + "px", top: y + "px" });
|
|
337
348
|
sec.appendChild(m);
|
|
338
349
|
}
|
|
@@ -379,8 +390,22 @@
|
|
|
379
390
|
const fmtTranslate = (x, y) => (r1(x) || r1(y) ? `${r1(x)}px ${r1(y)}px` : "");
|
|
380
391
|
const nameOf = (el, sec) => { const e = el.dataset.eid || ""; return e.startsWith(sec.dataset.eid + "/") ? e.slice(sec.dataset.eid.length) : e; };
|
|
381
392
|
|
|
393
|
+
/* The selection: one element, or several on the same slide (⌘-click adds and removes). `el` is the one
|
|
394
|
+
clicked last: it is what Enter edits and what the assistant gets first. Several move together and have no
|
|
395
|
+
resize handles. */
|
|
396
|
+
const many = () => sel.els.length > 1;
|
|
397
|
+
const groupName = () => many() ? `${sel.els.length} elements (${sel.els.map((e) => nameOf(e, sel.sec)).join(", ")})` : nameOf(sel.el, sel.sec);
|
|
398
|
+
function unionBox(els, sec) {
|
|
399
|
+
const bs = els.map((e) => boxOf(e, sec));
|
|
400
|
+
const x = Math.min(...bs.map((b) => b.x)), y = Math.min(...bs.map((b) => b.y));
|
|
401
|
+
return { x, y, w: Math.max(...bs.map((b) => b.x + b.w)) - x, h: Math.max(...bs.map((b) => b.y + b.h)) - y };
|
|
402
|
+
}
|
|
382
403
|
function drawSel() {
|
|
383
|
-
if (
|
|
404
|
+
if (sel) {
|
|
405
|
+
sel.els = sel.els.filter((e) => e.isConnected);
|
|
406
|
+
if (!sel.el.isConnected) sel.el = sel.els.at(-1);
|
|
407
|
+
}
|
|
408
|
+
if (!sel?.el) return removeSel();
|
|
384
409
|
if (editing) return;
|
|
385
410
|
if (!selBox) {
|
|
386
411
|
selBox = document.createElement("div");
|
|
@@ -388,34 +413,46 @@
|
|
|
388
413
|
selBox.innerHTML = '<span class="__sel-label"></span>' + HANDLES.map((h) => `<i data-h="${h}"></i>`).join("");
|
|
389
414
|
}
|
|
390
415
|
if (selBox.parentElement !== sel.sec) sel.sec.appendChild(selBox);
|
|
391
|
-
const b = boxOf(sel.el, sel.sec);
|
|
416
|
+
const b = many() ? unionBox(sel.els, sel.sec) : boxOf(sel.el, sel.sec);
|
|
417
|
+
selBox.classList.toggle("multi", many());
|
|
418
|
+
for (const e of sel.els) e.classList.toggle("__member", many());
|
|
392
419
|
Object.assign(selBox.style, { left: b.x + "px", top: b.y + "px", width: b.w + "px", height: b.h + "px" });
|
|
393
420
|
selBox.classList.toggle("readonly", !editable);
|
|
394
421
|
const lab = selBox.firstChild;
|
|
395
|
-
lab.textContent = `${nameOf(sel.el, sel.sec)} ` + (drag?.kind === "move" ? `x ${b.x} y ${b.y}` : `${b.w} × ${b.h}`);
|
|
422
|
+
lab.textContent = `${many() ? sel.els.length + " elements" : nameOf(sel.el, sel.sec)} ` + (drag?.kind === "move" ? `x ${b.x} y ${b.y}` : `${b.w} × ${b.h}`);
|
|
396
423
|
lab.className = "__sel-label " + (b.y < 30 ? "below" : "above");
|
|
397
424
|
}
|
|
398
|
-
function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected")); }
|
|
399
|
-
function select(el, sec, notify = true) {
|
|
425
|
+
function removeSel() { selBox?.remove(); document.querySelectorAll(".__selected").forEach((e) => e.classList.remove("__selected", "__member")); }
|
|
426
|
+
function select(el, sec, notify = true, els = el ? [el] : []) {
|
|
400
427
|
removeSel();
|
|
401
|
-
sel = el ? { el, sec } : null;
|
|
402
|
-
if (sel) { sel.
|
|
428
|
+
sel = el ? { el, sec, els } : null;
|
|
429
|
+
if (sel) { sel.els.forEach((e) => e.classList.add("__selected")); drawSel(); }
|
|
403
430
|
if (notify) host()?.selectionChanged(sel ? selectionInfo() : null);
|
|
404
431
|
}
|
|
432
|
+
// ⌘-click: add an element to the selection, or take it out. An element and one inside it cannot move
|
|
433
|
+
// together (the inner one would move twice), so adding one drops the other.
|
|
434
|
+
function toggleSel(t) {
|
|
435
|
+
if (!sel || sel.sec !== t.sec) return select(t.el, t.sec);
|
|
436
|
+
const els = sel.els.includes(t.el) ? sel.els.filter((e) => e !== t.el)
|
|
437
|
+
: [...sel.els.filter((e) => !e.contains(t.el) && !t.el.contains(e)), t.el];
|
|
438
|
+
if (!els.length) return select(null, null);
|
|
439
|
+
select(els.includes(t.el) ? t.el : els.at(-1), t.sec, true, els);
|
|
440
|
+
}
|
|
405
441
|
function selectionInfo() {
|
|
406
442
|
const { el, sec } = sel;
|
|
407
|
-
|
|
443
|
+
const info = (e) => ({ eid: e.dataset.eid, tag: tagOf(e), box: boxOf(e, sec) });
|
|
444
|
+
return { ...info(el), slide: slideInfo(sec), text: textOf(el).slice(0, 120),
|
|
445
|
+
...(many() ? { also: sel.els.filter((e) => e !== el).map(info) } : {}) };
|
|
408
446
|
}
|
|
409
|
-
function selectEid(
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
select(el, sec);
|
|
447
|
+
function selectEid(eids) {
|
|
448
|
+
const els = [].concat(eids || []).map((eid) => byEid(eid)).filter((e) => e?.closest(SLIDE_OF) === deck.current());
|
|
449
|
+
if (!els.length) return select(null, null);
|
|
450
|
+
select(els[0], deck.current(), true, els);
|
|
414
451
|
}
|
|
415
452
|
|
|
416
453
|
function selectHover(e) {
|
|
417
454
|
const t = selectableOf(e.target);
|
|
418
|
-
if (!t || (
|
|
455
|
+
if (!t || sel?.els.includes(t.el)) return hideHover();
|
|
419
456
|
if (!hoverBox) { hoverBox = document.createElement("div"); hoverBox.className = "__hover"; hoverBox.appendChild(document.createElement("span")); }
|
|
420
457
|
const b = boxOf(t.el, t.sec);
|
|
421
458
|
if (hoverBox.parentElement !== t.sec) t.sec.appendChild(hoverBox);
|
|
@@ -427,11 +464,11 @@
|
|
|
427
464
|
}
|
|
428
465
|
|
|
429
466
|
/* Snap targets: slide edges and centre, the footer rule, and the edges and centres of other elements */
|
|
430
|
-
function snapTargets(sec,
|
|
467
|
+
function snapTargets(sec, skip) {
|
|
431
468
|
const xs = [0, W / 2, W], ys = [0, H / 2, H];
|
|
432
469
|
if (FOOTER_TOP != null && sec.querySelector(".slide-foot")) ys.push(FOOTER_TOP);
|
|
433
470
|
for (const o of sec.querySelectorAll("[data-eid]")) {
|
|
434
|
-
if (o === el || el.contains(o) || o.contains(el)) continue;
|
|
471
|
+
if (skip.some((el) => o === el || el.contains(o) || o.contains(el))) continue;
|
|
435
472
|
if (o.closest("svg") && o.tagName.toLowerCase() !== "svg") continue;
|
|
436
473
|
if (!o.getClientRects().length) continue;
|
|
437
474
|
const b = boxOf(o, sec);
|
|
@@ -472,30 +509,32 @@
|
|
|
472
509
|
hideHover();
|
|
473
510
|
// double click: edit the text under the pointer
|
|
474
511
|
const now = performance.now();
|
|
475
|
-
const dbl = now - lastDown.t < 450 && Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < 6 && lastDown.el === e.target;
|
|
512
|
+
const dbl = !(e.metaKey || e.ctrlKey) && now - lastDown.t < 450 && Math.hypot(e.clientX - lastDown.x, e.clientY - lastDown.y) < 6 && lastDown.el === e.target;
|
|
476
513
|
lastDown = { t: dbl ? 0 : now, x: e.clientX, y: e.clientY, el: e.target };
|
|
477
514
|
if (dbl && !e.target.closest?.(".__sel i")) return editAt(e.target, e.clientX, e.clientY);
|
|
478
515
|
const handle = e.target.closest?.(".__sel i[data-h]");
|
|
479
516
|
if (!handle) {
|
|
480
517
|
if (e.target.closest?.(".__pin")) return; // pin markers are removed in pin mode only
|
|
481
|
-
const t = selectableOf(e.target);
|
|
482
|
-
if (!t) return select(null, null);
|
|
518
|
+
const t = selectableOf(e.target), add = e.metaKey || e.ctrlKey;
|
|
519
|
+
if (!t) return add ? undefined : select(null, null);
|
|
483
520
|
if (t.locked) {
|
|
484
|
-
select(null, null);
|
|
521
|
+
if (!add) select(null, null); // a ⌘-click that misses keeps the group
|
|
485
522
|
host()?.toast(`${t.el.classList.contains("slide-foot") ? "The footer is" : "This element is"} set by brand.css and locked.`);
|
|
486
523
|
return;
|
|
487
524
|
}
|
|
488
|
-
if (
|
|
525
|
+
if (add) return toggleSel(t);
|
|
526
|
+
if (!sel?.els.includes(t.el)) select(t.el, t.sec);
|
|
489
527
|
}
|
|
490
|
-
if (!editable) { if (handle) host()?.toast("Claude is working. Moving and resizing resume when it has finished."); return; }
|
|
528
|
+
if (!editable) { if (handle) host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished."); return; }
|
|
491
529
|
const el = sel.el, cs = getComputedStyle(el);
|
|
492
530
|
const { s } = frame(sel.sec);
|
|
493
531
|
const [tx, ty] = translateOf(el);
|
|
532
|
+
const styles = (e) => ({ translate: e.style.translate, width: e.style.width, height: e.style.height, "max-width": e.style.maxWidth, "max-height": e.style.maxHeight });
|
|
494
533
|
drag = {
|
|
495
534
|
kind: handle ? "resize" : "move", handle: handle?.dataset.h, x0: e.clientX, y0: e.clientY, s, started: !!handle,
|
|
496
|
-
b: boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
|
|
497
|
-
maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec,
|
|
498
|
-
|
|
535
|
+
b: many() ? unionBox(sel.els, sel.sec) : boxOf(el, sel.sec), tx, ty, cw: parseFloat(cs.width) || 0, ch: parseFloat(cs.height) || 0,
|
|
536
|
+
maxW: cs.maxWidth, maxH: cs.maxHeight, targets: snapTargets(sel.sec, sel.els),
|
|
537
|
+
items: sel.els.map((e) => { const [x, y] = translateOf(e); return { el: e, tx: x, ty: y, before: styles(e) }; }),
|
|
499
538
|
};
|
|
500
539
|
document.documentElement.setPointerCapture?.(e.pointerId);
|
|
501
540
|
}
|
|
@@ -520,8 +559,8 @@
|
|
|
520
559
|
const sy = snapTo([b.y + dy, b.y + dy + b.h / 2, b.y + dy + b.h], targets.ys, tol);
|
|
521
560
|
if (sy) { dy += sy.d; gy.push(sy.at); }
|
|
522
561
|
}
|
|
523
|
-
el.style.translate = fmtTranslate(
|
|
524
|
-
drag.label = `Moved ${
|
|
562
|
+
for (const it of drag.items) it.el.style.translate = fmtTranslate(it.tx + dx, it.ty + dy);
|
|
563
|
+
drag.label = `Moved ${groupName()} by ${r1(dx)}, ${r1(dy)} px`;
|
|
525
564
|
} else {
|
|
526
565
|
const h = drag.handle;
|
|
527
566
|
let w = b.w, ht = b.h, ox = 0, oy = 0; // ox/oy: how far the left/top edge moves
|
|
@@ -564,26 +603,30 @@
|
|
|
564
603
|
clearGuides();
|
|
565
604
|
if (!sel) return;
|
|
566
605
|
drawSel();
|
|
567
|
-
if (d.started) commit(d.
|
|
606
|
+
if (d.started) commit(d.items, d.label);
|
|
568
607
|
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
608
|
+
// one edit (one undo step) for all the elements that changed
|
|
609
|
+
function commit(items, label) {
|
|
610
|
+
const ops = items.flatMap(({ el, before }) => {
|
|
611
|
+
const set = {}, now = { translate: el.style.translate, width: el.style.width, height: el.style.height,
|
|
612
|
+
"max-width": el.style.maxWidth, "max-height": el.style.maxHeight };
|
|
613
|
+
for (const k of Object.keys(now)) if (now[k] !== before[k]) set[k] = now[k] || null;
|
|
614
|
+
return Object.keys(set).length ? [{ eid: el.dataset.eid, set }] : [];
|
|
615
|
+
});
|
|
616
|
+
if (ops.length) host()?.commitEdit(ops, label);
|
|
575
617
|
host()?.selectionChanged(selectionInfo());
|
|
576
618
|
}
|
|
577
619
|
function nudge(dx, dy) {
|
|
578
620
|
if (!sel) return;
|
|
579
|
-
if (!editable) return host()?.toast("Claude is working. Moving and resizing resume when it has finished.");
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
621
|
+
if (!editable) return host()?.toast(lockReason || "Claude is working. Moving and resizing resume when it has finished.");
|
|
622
|
+
const ops = sel.els.map((el) => {
|
|
623
|
+
const [tx, ty] = translateOf(el);
|
|
624
|
+
el.style.translate = fmtTranslate(tx + dx, ty + dy);
|
|
625
|
+
return { eid: el.dataset.eid, set: { translate: el.style.translate || null } };
|
|
626
|
+
});
|
|
583
627
|
drawSel();
|
|
584
628
|
renderPins(pins);
|
|
585
|
-
host()?.commitEdit({
|
|
586
|
-
{ nudge: true, name: nameOf(el, sel.sec), dx, dy });
|
|
629
|
+
host()?.commitEdit(ops, { nudge: true, name: groupName(), dx, dy });
|
|
587
630
|
host()?.selectionChanged(selectionInfo());
|
|
588
631
|
}
|
|
589
632
|
function setMode(m) {
|
|
@@ -641,12 +684,12 @@
|
|
|
641
684
|
}
|
|
642
685
|
|
|
643
686
|
async function startEdit(t, x, y, opts = {}) {
|
|
644
|
-
if (!editable) return host()?.toast("Claude is working. Editing resumes when it has finished.");
|
|
687
|
+
if (!editable) return host()?.toast(lockReason || "Claude is working. Editing resumes when it has finished.");
|
|
645
688
|
if (editing) await finishEdit(true);
|
|
646
689
|
const src = opts.src ?? await host()?.textSource(t.eid, t.path);
|
|
647
690
|
if (src == null || !t.el.isConnected) return;
|
|
648
691
|
const el = t.el;
|
|
649
|
-
if (!sel || sel.el !== el) select(el.dataset.eid ? el : null, t.sec);
|
|
692
|
+
if (!sel || sel.el !== el || many()) select(el.dataset.eid ? el : null, t.sec); // text editing is one element at a time
|
|
650
693
|
removeSel();
|
|
651
694
|
hideHover();
|
|
652
695
|
editing = { ...t, rendered: el.innerHTML };
|
|
@@ -776,7 +819,7 @@
|
|
|
776
819
|
// a new bullet left empty is removed again
|
|
777
820
|
if (ed.isNew && isBlank(el)) {
|
|
778
821
|
el.remove();
|
|
779
|
-
if (sel?.el
|
|
822
|
+
if (sel?.els.includes(el)) select(null, null);
|
|
780
823
|
host()?.commitRemove(eid);
|
|
781
824
|
chainDirty = false;
|
|
782
825
|
return;
|
|
@@ -1087,7 +1130,7 @@
|
|
|
1087
1130
|
isEditing: () => !!editing,
|
|
1088
1131
|
deselect() { select(null, null); },
|
|
1089
1132
|
hasSelection: () => !!sel,
|
|
1090
|
-
setEditable(on) { editable = !!on; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
|
|
1133
|
+
setEditable(on, why = null) { editable = !!on; lockReason = why; if (!editable && editing) finishEdit(true); if (sel) drawSel(); },
|
|
1091
1134
|
renderPins,
|
|
1092
1135
|
async measure(index) {
|
|
1093
1136
|
goto(index);
|
|
@@ -1136,7 +1179,7 @@
|
|
|
1136
1179
|
const k = e.key, meta = e.metaKey || e.ctrlKey;
|
|
1137
1180
|
if (sel && mode === "select" && !meta && (k === "Delete" || k === "Backspace")) {
|
|
1138
1181
|
e.preventDefault(); e.stopImmediatePropagation();
|
|
1139
|
-
host()?.requestDelete(sel.
|
|
1182
|
+
host()?.requestDelete(sel.els.map((e) => e.dataset.eid));
|
|
1140
1183
|
return;
|
|
1141
1184
|
}
|
|
1142
1185
|
// arrows nudge the selection (Shift: 10 px) instead of changing slides
|