@chalky/chalky 0.1.6 → 0.1.7

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.
Files changed (38) hide show
  1. package/agent/extensions/board/browser.js +142 -1
  2. package/agent/extensions/card/browser.js +49 -6
  3. package/agent/extensions/card/index.ts +4 -1
  4. package/agent/extensions/doodle/README.md +1 -1
  5. package/agent/extensions/doodle/browser.js +20 -18
  6. package/agent/extensions/frame/browser.js +5 -3
  7. package/agent/extensions/note/browser.js +19 -16
  8. package/agent/extensions/page/page.html +1 -1
  9. package/agent/extensions/screen/browser.js +2 -2
  10. package/agent/extensions/screen/index.ts +7 -5
  11. package/agent/extensions/version/browser.js +3 -3
  12. package/node_modules/xlsx/CHANGELOG.md +361 -0
  13. package/node_modules/xlsx/LICENSE +201 -0
  14. package/node_modules/xlsx/README.md +41 -0
  15. package/node_modules/xlsx/bin/xlsx.njs +310 -0
  16. package/node_modules/xlsx/bower.json +22 -0
  17. package/node_modules/xlsx/dist/LICENSE +201 -0
  18. package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
  19. package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
  20. package/node_modules/xlsx/dist/cpexcel.js +1506 -0
  21. package/node_modules/xlsx/dist/shim.min.js +2 -0
  22. package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
  23. package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
  24. package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
  25. package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
  26. package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
  27. package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
  28. package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
  29. package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
  30. package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
  31. package/node_modules/xlsx/dist/zahl.d.ts +4 -0
  32. package/node_modules/xlsx/package.json +183 -0
  33. package/node_modules/xlsx/types/index.d.ts +1065 -0
  34. package/node_modules/xlsx/types/tsconfig.json +15 -0
  35. package/node_modules/xlsx/xlsx.js +28103 -0
  36. package/node_modules/xlsx/xlsx.mjs +28228 -0
  37. package/node_modules/xlsx/xlsxworker.js +14 -0
  38. package/package.json +1 -1
@@ -9,6 +9,8 @@
9
9
  // The shelf: a dot per board (yours is bigger). Hover it and the dots open into thumbnails, with + for a new board;
10
10
  // click one to go there. It stays clear of the chat panel: more thumbnails than fit scroll between ‹ and › (grey at
11
11
  // the ends; a new board scrolls to the end). Drag a card by its label onto a thumbnail to move it to that board, where it was ({type: "card_board"}).
12
+ // Zoom: each board keeps its own (in this browser), as CSS zoom on #top, so places stay in main-space px; the control
13
+ // at the bottom right (−, %, +, fit), or Ctrl/⌘ + wheel (a trackpad pinch). See setZoom below.
12
14
  // page messages board_view {board}: the board this page shows; board_add
13
15
  // GET /board {boards, current} event "board" {boards, added?, by?}
14
16
  const CSS = `
@@ -61,7 +63,22 @@ const CSS = `
61
63
  .boards-shelf .slot.drop .mini { outline: 2px solid var(--accent); outline-offset: 1px; }
62
64
  .boards-shelf .add { display: none; width: 122px; height: 70px; border-radius: 6px; /* a thumbnail's size, border included */ border: 1px dashed rgba(0, 0, 0, 0.25);
63
65
  color: var(--muted); font-size: 13px; align-self: flex-start; margin-top: 3px; }
64
- .boards-shelf.open .add { display: block; } /* only in the open shelf: at rest it's just the dots */`;
66
+ .boards-shelf.open .add { display: block; } /* only in the open shelf: at rest it's just the dots */
67
+ /* zoom: CSS zoom on #top scales everything in the main space; its padding (room for the palette, rail, input) stays
68
+ the same on screen */
69
+ #top { padding: calc(var(--palette) / var(--zoom, 1)) calc((var(--rail) + 28px) / var(--zoom, 1))
70
+ calc(var(--bar) / var(--zoom, 1)) calc((var(--rail) + 28px) / var(--zoom, 1)); }
71
+ .boards-zoom { position: fixed; right: 16px; bottom: 24px; z-index: 25; display: flex; align-items: center; gap: 2px; padding: 3px;
72
+ border-radius: 12px; background: var(--glass); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
73
+ border: 1px solid var(--edge); box-shadow: var(--shadow); font-size: 12px; color: var(--soft); }
74
+ .boards-zoom button { height: 26px; min-width: 26px; padding: 0 6px; border-radius: 8px; font-variant-numeric: tabular-nums; }
75
+ .boards-zoom button:hover { background: var(--hover); color: var(--text); }
76
+ .boards-zoom .pct { min-width: 46px; }
77
+ .boards-zoom .fit { font-size: 15px; }
78
+ /* the hand: drag the board to move around it; what is on it doesn't react meanwhile (apps' frames included) */
79
+ #top.panning { cursor: grab; }
80
+ #top.panning.grabbing { cursor: grabbing; }
81
+ #top.panning * { pointer-events: none !important; }`;
65
82
 
66
83
  export default async function (page) {
67
84
  document.head.insertAdjacentHTML("beforeend", `<style>${CSS}</style>`);
@@ -136,6 +153,129 @@ export default async function (page) {
136
153
  view.scrollLeft = s.offsetLeft - (view.clientWidth - s.offsetWidth) / 2;
137
154
  }
138
155
 
156
+ // ---- zoom: each board's own, kept in this browser (localStorage) ----
157
+ // CSS zoom on #top: places stay in main-space px (what cards, frames and marks store), and #top's scrollLeft/Top and
158
+ // clientWidth are in them too. Window px -> main px is (x - left) / zoom + scroll: the other extensions divide by
159
+ // #top's style.zoom where they turn the pointer into main px. Ctrl/⌘ + wheel (a trackpad pinch) zooms at the
160
+ // pointer; the control at the bottom right: −, the percentage (click: back to 100%), +, and fit (the whole board).
161
+ const MIN = 0.25, MAX = 2, KEY = "chalky.board-zoom";
162
+ const shot = new URLSearchParams(location.search).has("shot"); // the versions' headless view: always 100%
163
+ let zooms = {};
164
+ try { zooms = JSON.parse(localStorage.getItem(KEY)) || {}; } catch {}
165
+ const zoomOf = () => parseFloat(top.style.zoom) || 1;
166
+ function setZoom(z, at, save = true) { // at: the window point that stays put (else the middle of the view)
167
+ z = shot ? 1 : Math.min(MAX, Math.max(MIN, Math.round(z * 100) / 100));
168
+ const m = top.getBoundingClientRect(), z0 = zoomOf();
169
+ at ??= { x: m.left + m.width / 2, y: m.top + m.height / 2 };
170
+ const p = { x: (at.x - m.left) / z0 + top.scrollLeft, y: (at.y - m.top) / z0 + top.scrollTop }; // in main px
171
+ top.style.zoom = z === 1 ? "" : z;
172
+ top.style.setProperty("--zoom", z);
173
+ if (z !== z0) { top.scrollLeft = p.x - (at.x - m.left) / z; top.scrollTop = p.y - (at.y - m.top) / z; }
174
+ pct.textContent = `${Math.round(z * 100)}%`;
175
+ if (save) {
176
+ if (z === 1) delete zooms[current]; else zooms[current] = z;
177
+ try { localStorage.setItem(KEY, JSON.stringify(zooms)); } catch {}
178
+ }
179
+ }
180
+ function fitZoom() { // everything on this board in view
181
+ const els = [...container(current).children], z0 = zoomOf(), m = top.getBoundingClientRect();
182
+ if (!els.length) return setZoom(1);
183
+ const rs = els.map((el) => el.getBoundingClientRect()).filter((r) => r.width || r.height);
184
+ const main = (x, y) => ({ x: (x - m.left) / z0 + top.scrollLeft, y: (y - m.top) / z0 + top.scrollTop });
185
+ const a = main(Math.min(...rs.map((r) => r.left)), Math.min(...rs.map((r) => r.top)));
186
+ const b = main(Math.max(...rs.map((r) => r.right)), Math.max(...rs.map((r) => r.bottom)));
187
+ const s = getComputedStyle(document.documentElement), px = (v) => parseFloat(s.getPropertyValue(v)) || 0;
188
+ const side = px("--rail") + 28, w = innerWidth - 2 * side, h = innerHeight - px("--palette") - px("--bar"); // inside the padding
189
+ const z = Math.min(MAX, Math.max(MIN, Math.min(w / (b.x - a.x + 24), h / (b.y - a.y + 24), 1)));
190
+ setZoom(z);
191
+ top.scrollLeft = (a.x + b.x) / 2 - (m.width / 2) / z;
192
+ top.scrollTop = a.y - 12 - px("--palette") / z;
193
+ }
194
+ const zoomBar = Object.assign(document.createElement("div"), { className: "boards-zoom" });
195
+ zoomBar.dataset.name = "zoom";
196
+ const pct = button("pct", "100%", "Back to 100%", () => setZoom(1));
197
+ zoomBar.append(button("", "−", "Zoom out", () => setZoom(zoomOf() / 1.25)), pct,
198
+ button("", "+", "Zoom in", () => setZoom(zoomOf() * 1.25)), button("fit", "⤢", "Fit the whole board", fitZoom));
199
+ document.body.append(zoomBar);
200
+ const pinch = (e, at) => { // ctrl + wheel (a pinch on a trackpad sends that): zoom at the pointer, not the browser's
201
+ if (!e.ctrlKey && !e.metaKey) return;
202
+ e.preventDefault();
203
+ setZoom(zoomOf() * Math.exp(-e.deltaY * (e.deltaMode ? 0.05 : 0.01)), at);
204
+ };
205
+ // anywhere on the page, not only over the main space: over the messages panel (which lies over the board) a pinch
206
+ // would otherwise scroll the messages; captured, so nothing under the pointer scrolls with it
207
+ addEventListener("wheel", (e) => pinch(e, { x: e.clientX, y: e.clientY }), { passive: false, capture: true });
208
+ top.addEventListener("load", (e) => { // an app's frame (same site): its pinches too, its px turned into window px
209
+ const fr = e.target;
210
+ if (fr.localName !== "iframe") return;
211
+ try {
212
+ fr.contentWindow.addEventListener("wheel", (ev) => {
213
+ const r = fr.getBoundingClientRect(), k = zoomOf();
214
+ pinch(ev, { x: r.left + fr.clientLeft * k + ev.clientX * k, y: r.top + fr.clientTop * k + ev.clientY * k });
215
+ }, { passive: false });
216
+ } catch {} // another site's frame
217
+ }, true);
218
+
219
+ // ---- the hand: drag to move around the board (scrolls #top). The palette's hand tool (one tool at a time, Esc
220
+ // stops it); or hold Space (when not typing and no other tool is on); or drag with the middle button, any time.
221
+ // Two-finger scrolling on a trackpad moves around too (the browser's own scrolling).
222
+ const HAND = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V12"/>` +
223
+ `<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0V12"/><path d="M14 5.5a1.5 1.5 0 0 1 3 0V12"/>` +
224
+ `<path d="M17 7.5a1.5 1.5 0 0 1 3 0V16a6 6 0 0 1-6 6h-2 .2a6 6 0 0 1-5-2.7l-.2-.3c-.3-.5-1.4-2.4-3.3-5.7a1.5 1.5 0 0 1 .5-2 1.9 1.9 0 0 1 2.3.3L8 13"/></svg>`;
225
+ const handButton = Object.assign(document.createElement("button"), { innerHTML: HAND,
226
+ title: "Hand: drag to move around the board (or hold Space, or drag with the middle button; Esc to stop)" });
227
+ handButton.dataset.name = "hand";
228
+ handButton.style.order = 99; // the palette's last tool, at the right end, whichever script adds its tools after this one
229
+ page.slot("status").append(handButton);
230
+ let hand = false, spaceHand = false, panning = null; // panning: {x, y, left, top} at the press
231
+ const panMode = () => top.classList.toggle("panning", hand || spaceHand || !!panning);
232
+ function setHand(v) {
233
+ hand = v;
234
+ handButton.classList.toggle("on", v);
235
+ panMode();
236
+ if (v) document.dispatchEvent(new CustomEvent("tool-on", { detail: "hand" })); // the other tools turn off
237
+ }
238
+ handButton.onclick = () => setHand(!hand);
239
+ document.addEventListener("tool-on", (e) => { if (hand && e.detail !== "hand") setHand(false); });
240
+ const typing = () => document.activeElement?.matches?.("input, textarea, select, [contenteditable=''], [contenteditable='true']") ||
241
+ document.activeElement?.localName === "iframe"; // an app has the keys
242
+ const otherTool = () => !!document.querySelector("#status button.on:not([data-name='show-marks']):not([data-name='hand'])");
243
+ addEventListener("keydown", (e) => {
244
+ if (e.key === "Escape") setHand(false);
245
+ if (e.code !== "Space") return;
246
+ if (spaceHand) return e.preventDefault(); // held: its repeats don't scroll the page either
247
+ if (typing() || otherTool() || e.ctrlKey || e.metaKey || e.altKey) return;
248
+ e.preventDefault(); // not a page scroll, nor a click on the focused button
249
+ spaceHand = true;
250
+ panMode();
251
+ }, true);
252
+ addEventListener("keyup", (e) => { if (e.code === "Space" && spaceHand) { spaceHand = false; panMode(); } }, true);
253
+ addEventListener("blur", () => { spaceHand = false; panMode(); });
254
+ top.addEventListener("pointerdown", (e) => {
255
+ if (!(e.button === 1 || (e.button === 0 && (hand || spaceHand)))) return;
256
+ e.preventDefault();
257
+ e.stopImmediatePropagation(); // not a click, a line or a frame on what is under it
258
+ panning = { x: e.clientX, y: e.clientY, left: top.scrollLeft, top: top.scrollTop, id: e.pointerId };
259
+ top.setPointerCapture(e.pointerId);
260
+ top.classList.add("grabbing");
261
+ panMode();
262
+ }, true);
263
+ top.addEventListener("pointermove", (e) => {
264
+ if (!panning || e.pointerId !== panning.id) return;
265
+ const z = zoomOf(); // window px moved -> main px scrolled, so the board stays under the pointer
266
+ top.scrollLeft = panning.left - (e.clientX - panning.x) / z;
267
+ top.scrollTop = panning.top - (e.clientY - panning.y) / z;
268
+ });
269
+ const stopPan = (e) => {
270
+ if (!panning || e.pointerId !== panning.id) return;
271
+ panning = null;
272
+ top.classList.remove("grabbing");
273
+ panMode();
274
+ };
275
+ top.addEventListener("pointerup", stopPan);
276
+ top.addEventListener("pointercancel", stopPan);
277
+ top.addEventListener("auxclick", (e) => { if (e.button === 1) e.preventDefault(); }); // the middle button doesn't open links
278
+
139
279
  const others = document.createElement("style"); // hides what is on the body for other boards (a note's pin)
140
280
  document.head.append(others);
141
281
  function show(n, tell = true) { // go to board n (tell: the server, which the first show got it from)
@@ -145,6 +285,7 @@ export default async function (page) {
145
285
  document.body.dataset.board = n;
146
286
  others.textContent = `body > [data-board]:not([data-board="${n}"]) { display: none !important; }`;
147
287
  for (const b of boards) container(b).classList.toggle("on", b === n);
288
+ setZoom(zooms[n] ?? 1, null, false); // each board keeps its own zoom (in this browser)
148
289
  if (tell) page.send({ type: "board_view", board: n });
149
290
  render();
150
291
  }
@@ -3,6 +3,7 @@
3
3
  // A card is bare: only what the agent built, no frame (its label shows on hover: name, owner, ✕). It is
4
4
  // stacked in the main space, its content keeping its own size; or pinned at place {x, y, width?, height?}
5
5
  // (px from the main space's top-left, so it scrolls with the page), its root block then filling it.
6
+ // (a new card with no place gets one near the middle of the view: autoPlace)
6
7
  // What the user can do with the label (the handle, so clicks inside still work):
7
8
  // drag move it; a drop sends {type: "card_move", card, place} (unless draggable: false)
8
9
  // drag and hold still over another card (it lights up), release combine: it slides back and the page
@@ -287,11 +288,11 @@ export default async function (page) {
287
288
 
288
289
  page.on("card", (ev) => {
289
290
  if (ev.op === "render") {
290
- const old = cards[ev.card] || {};
291
+ const isNew = !cards[ev.card], old = cards[ev.card] || {};
291
292
  const { op, type, card, ...record } = ev;
292
293
  cards[ev.card] = { ...record, box: old.box, label: old.label, body: old.body, nodes: old.nodes, frame: old.frame,
293
294
  flipped: old.flipped, option: record.options ? old.option : 0 };
294
- draw(ev.card);
295
+ draw(ev.card).then(() => { if (isNew && !record.place) autoPlace(ev.card); });
295
296
  } else if (ev.op === "delete" && cards[ev.card]) {
296
297
  cards[ev.card].box?.remove();
297
298
  delete cards[ev.card];
@@ -306,6 +307,47 @@ export default async function (page) {
306
307
  }
307
308
  });
308
309
 
310
+ // A new card the agent gave no place: near the middle of what the user sees (between the panel, if open, and the
311
+ // right edge; below the palette, above the input bar), clear of everything else on the board. The nearest free
312
+ // spot to the middle, on a 40 px grid, keeping a 24 px gap. Only the tab in view, and only on the board shown
313
+ // (a card for another board stays stacked). Sent as card_place: saved, with no step of its own in the history.
314
+ const GAP = 24, STEP = 40, RINGS = 60;
315
+ async function autoPlace(id) {
316
+ const s = cards[id];
317
+ if (!s?.box || s.place || document.visibilityState !== "visible") return;
318
+ if ((Number(s.board) || 1) !== (Number(document.body.dataset.board) || 1)) return;
319
+ await new Promise(requestAnimationFrame); // its blocks drawn
320
+ if (s.place) return;
321
+ const box = s.box;
322
+ box.toggleAttribute("data-placed", true); // its size as a placed card, off the stack
323
+ Object.assign(box.style, { position: "absolute", margin: "0", left: "0px", top: "0px", visibility: "hidden" });
324
+ const z = parseFloat(area.style.zoom) || 1, w = box.offsetWidth, h = box.offsetHeight;
325
+ const m = area.getBoundingClientRect();
326
+ const main = (x, y) => ({ x: (x - m.left) / z + area.scrollLeft, y: (y - m.top) / z + area.scrollTop });
327
+ const css = getComputedStyle(document.documentElement), px = (v) => parseFloat(css.getPropertyValue(v)) || 0;
328
+ const panel = document.getElementById("panel"), open = ["messages", "versions"].includes(document.body.dataset.panel);
329
+ const side = px("--rail") + 28;
330
+ const a = main(Math.max(side, open && panel ? panel.getBoundingClientRect().right : 0), px("--palette"));
331
+ const b = main(innerWidth - side, innerHeight - px("--bar"));
332
+ const cx = (a.x + b.x) / 2 - w / 2, cy = (a.y + b.y) / 2 - h / 2;
333
+ const others = [...(box.parentElement?.children ?? [])].filter((el) => el !== box).map((el) => el.getBoundingClientRect())
334
+ .filter((r) => r.width && r.height).map((r) => ({ ...main(r.left, r.top), w: r.width / z, h: r.height / z }));
335
+ const free = (x, y) => others.every((o) => x + w + GAP <= o.x || o.x + o.w + GAP <= x || y + h + GAP <= o.y || o.y + o.h + GAP <= y);
336
+ const spots = [];
337
+ for (let i = -RINGS; i <= RINGS; i++) for (let j = -RINGS; j <= RINGS; j++) {
338
+ const x = Math.round(cx + i * STEP), y = Math.round(cy + j * STEP);
339
+ if (x >= 0 && y >= 0) spots.push({ x, y, d: (i * i + j * j) });
340
+ }
341
+ spots.sort((p, q) => p.d - q.d);
342
+ const spot = spots.find((p) => free(p.x, p.y)) ?? { x: Math.max(0, Math.round(cx)), y: Math.max(0, Math.round(cy)) };
343
+ // an app keeps its width: a placed box with none shrinks to the room left of the window's edge, and in a narrower
344
+ // window (the card_show check's browser) an app far right would be 0px wide
345
+ s.place = s.app ? { x: spot.x, y: spot.y, width: w } : { x: spot.x, y: spot.y };
346
+ Object.assign(box.style, { left: `${spot.x}px`, top: `${spot.y}px`, visibility: "" });
347
+ if (s.app) { box.style.width = `${w}px`; s.frame && (s.frame.style.width = "100%"); }
348
+ page.send({ type: "card_place", card: id, place: s.place });
349
+ }
350
+
309
351
  function pinStacked() { // pin each stacked card where it is now, so taking one out of the stack moves none
310
352
  const stacked = Object.entries(cards).filter(([, s]) => !s.place && s.box);
311
353
  for (const [, s] of stacked) s.place = { x: s.box.offsetLeft, y: s.box.offsetTop }; // read all, then move
@@ -325,6 +367,7 @@ export default async function (page) {
325
367
  const handle = e.currentTarget, box = s.box;
326
368
  handle.setPointerCapture(e.pointerId); // keep getting moves over iframes and other cards
327
369
  let place = null, held = false, x0 = 0, y0 = 0;
370
+ const z = parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js): window px moved -> main px
328
371
  let under = null, armed = null, wait = null, sx = 0, sy = 0; // the card under the pointer; armed: lit, ready to combine
329
372
  const hold = setTimeout(() => { held = true; page.send({ type: "card_pin", card: id, pinned: !s.pinned }); }, HOLD_MS);
330
373
  handle.onpointermove = (ev) => {
@@ -332,7 +375,7 @@ export default async function (page) {
332
375
  clearTimeout(hold);
333
376
  if (s.pinned || s.draggable === false) return;
334
377
  if (!place) { pinStacked(); x0 = box.offsetLeft; y0 = box.offsetTop; box.style.zIndex = 4; } // offsets in the main space; above the rest while dragged
335
- place = { ...s.place, x: Math.round(x0 + ev.clientX - e.clientX), y: Math.round(y0 + ev.clientY - e.clientY) };
378
+ place = { ...s.place, x: Math.round(x0 + (ev.clientX - e.clientX) / z), y: Math.round(y0 + (ev.clientY - e.clientY) / z) };
336
379
  box.toggleAttribute("data-placed", true);
337
380
  Object.assign(box.style, { position: "absolute", margin: "0", left: `${place.x}px`, top: `${place.y}px` });
338
381
  const now = cardAt(ev.clientX, ev.clientY, box);
@@ -366,12 +409,12 @@ export default async function (page) {
366
409
  if (s.pinned) return;
367
410
  pinStacked();
368
411
  const handle = e.currentTarget, box = s.box;
369
- const x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
412
+ const x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight, z = parseFloat(area.style.zoom) || 1;
370
413
  handle.setPointerCapture(e.pointerId);
371
414
  let place = null;
372
415
  handle.onpointermove = (ev) => {
373
- place = { x, y, width: Math.min(area.clientWidth - x, Math.max(40, Math.round(w0 + ev.clientX - e.clientX))),
374
- height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
416
+ place = { x, y, width: Math.min(area.clientWidth - x, Math.max(40, Math.round(w0 + (ev.clientX - e.clientX) / z))),
417
+ height: Math.max(30, Math.round(h0 + (ev.clientY - e.clientY) / z)) };
375
418
  box.toggleAttribute("data-placed", true);
376
419
  Object.assign(box.style, { position: "absolute", margin: "0", left: `${x}px`, top: `${y}px`,
377
420
  width: `${place.width}px`, height: `${place.height}px` });
@@ -16,7 +16,7 @@
16
16
  // GET /card/all every card GET /card/about block name -> its about block
17
17
  // GET /card/blocks block name -> mtime GET /card/blocks/<Name>.js, /card/node_modules/{marked,dompurify}/...
18
18
  // GET /card/lib/... libraries the agents added for blocks (lib/ here: ES-module builds)
19
- // page messages card_set, card_prop, card_move, card_board, card_pin, card_keep, card_close, card_action
19
+ // page messages card_set, card_prop, card_move, card_place, card_board, card_pin, card_keep, card_close, card_action
20
20
  // page event "card" {op: "render", card, ...record} | {op: "delete", card} | {op: "reset", cards}
21
21
  import fs from "node:fs";
22
22
  import path from "node:path";
@@ -344,6 +344,9 @@ export default function (pi: ExtensionAPI) {
344
344
  else if (type === "card_move") {
345
345
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
346
346
  r.place = msg.place; cards.change(id, r, "user", `moved ${id}`);
347
+ } else if (type === "card_place") { // the page put a new card with no place near the middle of the view: not a move
348
+ if (r.place || r.pinned) return; // the user saw it appear there, so no step of its own (it is part of the showing)
349
+ r.place = msg.place; cards.change(id, r);
347
350
  } else if (type === "card_board" && host.api.board?.has(msg.board)) { // dropped on another board's thumbnail
348
351
  if (r.pinned) return host.broadcast("card", { op: "render", card: id, ...r });
349
352
  r.board = msg.board; r.place = msg.place ?? null; cards.change(id, r, "user", `moved ${id} to board ${msg.board}`);
@@ -29,7 +29,7 @@ You name the thing; the page draws it by hand in your colour, and keeps it on th
29
29
  page (the mark goes on just those characters), element a CSS selector or an element's id or data-name (also inside
30
30
  an app's frame), mark one of the marks (theirs or yours: m7). Example: {"card": "shop", "text": "12.50"}.
31
31
  - The page's own controls can be marked too, by name: {element: "pen"} (the marker), "text", "eraser", "show-marks"
32
- (the eye), "note" (the note pencil), "frames", "files", "dictate" (the mic), "tools" (the whole palette, top centre),
32
+ (the eye), "note" (the speech bubble), "frames", "files", "dictate" (the mic), "tools" (the whole palette, top centre),
33
33
  "rail" (left edge), "messages", "versions", "message-line" (bottom), "boards" (the shelf above it), "new-board". Such
34
34
  marks stay fixed to the window. Good for a tour: ring "pen" with "draw here".
35
35
  - at: {x, y, width, height} in window px, as screen_see's picture shows it: a last resort for something with no name
@@ -175,13 +175,14 @@ export default async function (page) {
175
175
  const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
176
176
 
177
177
  // ---- where things are ----
178
+ const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js): window px per main px (and per app-frame px)
178
179
  const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
179
- const m = area.getBoundingClientRect();
180
- return { x: x - m.left + area.scrollLeft, y: y - m.top + area.scrollTop };
180
+ const m = area.getBoundingClientRect(), z = zoom();
181
+ return { x: (x - m.left) / z + area.scrollLeft, y: (y - m.top) / z + area.scrollTop };
181
182
  };
182
183
  const inWindow = (p) => {
183
- const m = area.getBoundingClientRect();
184
- return { x: p.x + m.left - area.scrollLeft, y: p.y + m.top - area.scrollTop };
184
+ const m = area.getBoundingClientRect(), z = zoom();
185
+ return { x: m.left + (p.x - area.scrollLeft) * z, y: m.top + (p.y - area.scrollTop) * z };
185
186
  };
186
187
  const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
187
188
 
@@ -266,7 +267,7 @@ export default async function (page) {
266
267
  const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
267
268
  const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
268
269
  board: Number(document.body.dataset.board) || 1, // the board shown (board/)
269
- area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
270
+ area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w / zoom()), height: Math.round(box.h / zoom()) } };
270
271
  step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
271
272
  steps.push(step);
272
273
  for (const m of marks) m.el.remove();
@@ -303,10 +304,10 @@ export default async function (page) {
303
304
  doc.head.append(s);
304
305
  classes(doc.documentElement);
305
306
  }
306
- const offset = () => {
307
- if (!frameEl) return { x: 0, y: 0 };
308
- const r = frameEl.getBoundingClientRect();
309
- return { x: r.left + frameEl.clientLeft, y: r.top + frameEl.clientTop };
307
+ const offset = () => { // an app frame's (0, 0) in window px, and window px per frame px (k: zoomed with the main space)
308
+ if (!frameEl) return { x: 0, y: 0, k: 1 };
309
+ const r = frameEl.getBoundingClientRect(), k = zoom();
310
+ return { x: r.left + frameEl.clientLeft * k, y: r.top + frameEl.clientTop * k, k };
310
311
  };
311
312
  const root = frameEl ? doc : area;
312
313
  root.addEventListener("pointerdown", (e) => {
@@ -316,16 +317,16 @@ export default async function (page) {
316
317
  if (erasing) { // the press is for the eraser, not for what's under it
317
318
  e.preventDefault();
318
319
  press = { erase: true, win };
319
- eraseAt(inMain(e.clientX + o.x, e.clientY + o.y));
320
+ eraseAt(inMain(o.x + e.clientX * o.k, o.y + e.clientY * o.k));
320
321
  return;
321
322
  }
322
323
  grabbed = marks.length ? frame() : null; // before the click acts
323
324
  if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
324
325
  if (texting) e.preventDefault(); // the press is for the words (or a line), not for what's under it
325
- press = { x: e.clientX + o.x, y: e.clientY + o.y, id: e.pointerId, target: e.target, win, o, text: texting };
326
+ press = { x: o.x + e.clientX * o.k, y: o.y + e.clientY * o.k, id: e.pointerId, target: e.target, win, o, text: texting };
326
327
  }, true);
327
328
  win.addEventListener("pointermove", (e) => {
328
- const o = offset(), x = e.clientX + o.x, y = e.clientY + o.y;
329
+ const o = offset(), x = o.x + e.clientX * o.k, y = o.y + e.clientY * o.k;
329
330
  over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
330
331
  if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
331
332
  if (!press || press.win !== win || !drawing) return;
@@ -495,7 +496,7 @@ export default async function (page) {
495
496
  hideAsk();
496
497
  const last = marks.at(-1).points.at(-1), to = document.getElementById("to")?.value || "agent1";
497
498
  ask = Object.assign(document.createElement("form"), { className: "doodle-ask" });
498
- const m = area.getBoundingClientRect(), room = inMain(m.left + area.clientWidth - 316, m.bottom - 140); // inside the view (not under the scrollbar)
499
+ const m = area.getBoundingClientRect(), z = zoom(), room = inMain(m.left + (area.clientWidth - 316) * z, m.bottom - 140 * z); // inside the view (not under the scrollbar)
499
500
  Object.assign(ask.style, { left: `${Math.min(last.x + 16, room.x)}px`, top: `${Math.min(last.y + 12, room.y)}px` });
500
501
  const field = Object.assign(document.createElement("input"), { placeholder: `Say something… (Enter sends to ${to})`, autocomplete: "off" });
501
502
  field.setAttribute("aria-label", "Words to send with your marks");
@@ -527,10 +528,11 @@ export default async function (page) {
527
528
  const distBox = (b, q) => Math.hypot(Math.max(b.x - q.x, 0, q.x - b.x - b.w), Math.max(b.y - q.y, 0, q.y - b.y - b.h));
528
529
  const inPoly = (pts, q) => { let c = false; for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { const a = pts[i], b = pts[j];
529
530
  if ((a.y > q.y) !== (b.y > q.y) && q.x < (b.x - a.x) * (q.y - a.y) / (b.y - a.y) + a.x) c = !c; } return c; };
530
- const frameOffset = (fr) => { const r = fr.getBoundingClientRect(); return { x: r.left + fr.clientLeft, y: r.top + fr.clientTop }; };
531
- function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in
532
- const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0 }, p = inMain(r.left + o.x, r.top + o.y);
533
- return { x: p.x, y: p.y, w: r.width, h: r.height };
531
+ const frameOffset = (fr) => { const r = fr.getBoundingClientRect(), k = zoom(); return { x: r.left + fr.clientLeft * k, y: r.top + fr.clientTop * k, k }; };
532
+ function rectOf(el, fr) { // an element (or a Range), in main px; fr: the app frame it is in (its px: main px)
533
+ const r = el.getBoundingClientRect(), o = fr ? frameOffset(fr) : { x: 0, y: 0, k: 1 }, p = inMain(o.x + r.left * o.k, o.y + r.top * o.k);
534
+ const s = fr ? 1 : 1 / zoom(); // window px -> main px
535
+ return { x: p.x, y: p.y, w: r.width * s, h: r.height * s };
534
536
  }
535
537
  const scopes = (el) => [{ root: el, fr: null }, ...[...(el.localName === "iframe" ? [el] : el.querySelectorAll("iframe"))].flatMap((fr) => {
536
538
  try { return fr.contentDocument?.body ? [{ root: fr.contentDocument.body, fr }] : []; } catch { return []; } // other origins: not ours
@@ -671,7 +673,7 @@ export default async function (page) {
671
673
  if (under && !area.contains(under) && under !== document.documentElement && under !== document.body)
672
674
  return { box: { x, y, w, h }, board: Number(board()), fixed: true };
673
675
  const p = inMain(x, y);
674
- return { box: { x: p.x, y: p.y, w, h }, board: Number(board()) };
676
+ return { box: { x: p.x, y: p.y, w: w / zoom(), h: h / zoom() }, board: Number(board()) };
675
677
  }
676
678
  function findMain(t, every) {
677
679
  if (t.mark) {
@@ -71,9 +71,10 @@ export default async function (page) {
71
71
  const board = () => Number(document.body.dataset.board) || 1; // the board shown (board/): frames are drawn and sent there
72
72
  document.addEventListener("board", () => setTimeout(() => render(shown))); // after the switch: count that board's
73
73
 
74
+ const zoom = () => parseFloat(area.style.zoom) || 1; // the board's zoom (board/browser.js)
74
75
  const inMain = (x, y) => { // window px -> main-space px (as note/browser.js)
75
- const m = area.getBoundingClientRect();
76
- return { x: Math.round(x - m.left + area.scrollLeft), y: Math.round(y - m.top + area.scrollTop) };
76
+ const m = area.getBoundingClientRect(), z = zoom();
77
+ return { x: Math.round((x - m.left) / z + area.scrollLeft), y: Math.round((y - m.top) / z + area.scrollTop) };
77
78
  };
78
79
  const put = (box, f) => Object.assign(box.style, { left: `${f.x}px`, top: `${f.y}px`, width: `${f.width}px`, height: `${f.height}px` });
79
80
 
@@ -154,8 +155,9 @@ export default async function (page) {
154
155
  const corner = e.currentTarget, x = box.offsetLeft, y = box.offsetTop, w0 = box.offsetWidth, h0 = box.offsetHeight;
155
156
  corner.setPointerCapture(e.pointerId);
156
157
  let place = null;
158
+ const z = zoom(); // window px moved -> main px
157
159
  corner.onpointermove = (ev) => {
158
- place = { x, y, width: Math.max(40, Math.round(w0 + ev.clientX - e.clientX)), height: Math.max(30, Math.round(h0 + ev.clientY - e.clientY)) };
160
+ place = { x, y, width: Math.max(40, Math.round(w0 + (ev.clientX - e.clientX) / z)), height: Math.max(30, Math.round(h0 + (ev.clientY - e.clientY) / z)) };
159
161
  put(box, place);
160
162
  };
161
163
  corner.onpointerup = () => {
@@ -1,5 +1,5 @@
1
- // The page side of the note extension. A pencil button in the tool palette (top) turns on a clear
2
- // layer over the page; the cursor is a pen, blue over something a click would point at.
1
+ // The page side of the note extension. A speech-bubble button in the tool palette (top) turns on a clear
2
+ // layer over the page; the cursor is a speech bubble, blue over something a click would point at.
3
3
  // Click: a note box opens there. Hold the button and draw: on release the note box opens, and the note
4
4
  // covers the drawn area (its bounding box, in main-space px: where card_show's place puts a card).
5
5
  // The user sees only the box; the target goes to the agent with the note: card and block (from
@@ -28,11 +28,11 @@ export default async function (page) {
28
28
 
29
29
  const layer = document.createElement("div"); // catches clicks, so the page underneath doesn't react
30
30
  layer.style.cssText = "position:fixed;inset:0;z-index:9998;display:none";
31
- // The cursor: the button's pen (white edge, so it shows on dark too), its tip (4, 20) is the spot pointed at.
31
+ // The cursor: the button's speech bubble (white edge, so it shows on dark too); its tail's tip (2, 22) is the spot pointed at.
32
+ const BUBBLE = "M7.9 20A9 9 0 1 0 4 16.1L2 22z";
32
33
  const pen = (color) => `url("data:image/svg+xml,${encodeURIComponent(
33
34
  `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
34
- `<g stroke="#fff" stroke-width="4"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g>` +
35
- `<g stroke="${color}" stroke-width="2"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></g></svg>`)}") 4 20, crosshair`;
35
+ `<path d="${BUBBLE}" stroke="#fff" stroke-width="4"/><path d="${BUBBLE}" stroke="${color}" stroke-width="2"/></svg>`)}") 2 22, crosshair`;
36
36
  const PEN = pen("#6e6e73"), PEN_ON = pen("#0071e3"); // grey: empty space; blue: over a thing
37
37
  layer.style.cursor = PEN;
38
38
  const input = document.createElement("input"); // the note box, opened where the user clicked
@@ -60,7 +60,7 @@ export default async function (page) {
60
60
  document.body.append(layer, ink, input);
61
61
 
62
62
  const button = document.createElement("button");
63
- button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13 7 4 4"/></svg>`;
63
+ button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${BUBBLE}"/></svg>`; // a speech bubble
64
64
  button.title = "Point: click anything, or hold and draw around an area, then write a note (Esc to stop)";
65
65
  button.dataset.name = "note"; // names an agent's pen can point at
66
66
  page.slot("status").prepend(button); // the first tool in the palette, whichever script loads first
@@ -79,8 +79,13 @@ export default async function (page) {
79
79
  [...e.classList].slice(0, 2).map((c) => `.${c}`).join("");
80
80
 
81
81
  function inMain(x, y) { // window px -> main-space px (the main space scrolls; card_show's place uses these)
82
- const main = page.slot("top"), m = main.getBoundingClientRect();
83
- return { x: Math.round(x - m.left + main.scrollLeft), y: Math.round(y - m.top + main.scrollTop) };
82
+ const main = page.slot("top"), m = main.getBoundingClientRect(), z = zoom(); // the board's zoom (board/browser.js)
83
+ return { x: Math.round((x - m.left) / z + main.scrollLeft), y: Math.round((y - m.top) / z + main.scrollTop) };
84
+ }
85
+ function zoom() { return parseFloat(page.slot("top").style.zoom) || 1; } // window px per main px (and per app-frame px)
86
+ function inFrame(fr, x, y) { // window px -> px in an app's frame (zoomed with the main space)
87
+ const r = fr.getBoundingClientRect(), z = zoom();
88
+ return [(x - r.left) / z - fr.clientLeft, (y - r.top) / z - fr.clientTop];
84
89
  }
85
90
 
86
91
  function target(x, y) {
@@ -89,9 +94,9 @@ export default async function (page) {
89
94
  let inner = null, frame = null, box = el.getBoundingClientRect();
90
95
  if (el.localName === "iframe") { // look inside the frame (an App): same origin, so its document is readable
91
96
  try {
92
- const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
93
- inner = el.contentDocument?.elementFromPoint(x - dx, y - dy);
94
- if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(b.x + dx, b.y + dy, b.width, b.height); }
97
+ const r = el.getBoundingClientRect(), z = zoom(), dx = r.left + el.clientLeft * z, dy = r.top + el.clientTop * z;
98
+ inner = el.contentDocument?.elementFromPoint(...inFrame(el, x, y));
99
+ if (inner) { frame = el; const b = inner.getBoundingClientRect(); box = new DOMRect(dx + b.x * z, dy + b.y * z, b.width * z, b.height * z); }
95
100
  } catch {}
96
101
  }
97
102
  const hitEl = inner || el;
@@ -110,8 +115,7 @@ export default async function (page) {
110
115
  let part = null; // what the thing itself says is at this spot (partAt), e.g. {"name": "enemy-2"}
111
116
  try {
112
117
  if (frame?.contentWindow?.partAt) {
113
- const r = frame.getBoundingClientRect();
114
- part = frame.contentWindow.partAt(x - r.left - frame.clientLeft, y - r.top - frame.clientTop);
118
+ part = frame.contentWindow.partAt(...inFrame(frame, x, y));
115
119
  } else {
116
120
  const owner = [hitEl, ...[...document.elementsFromPoint(x, y)]].find((e) => typeof e?.partAt === "function");
117
121
  if (owner) { const r = owner.getBoundingClientRect(); part = owner.partAt(x - r.left, y - r.top); }
@@ -188,8 +192,7 @@ export default async function (page) {
188
192
  const el = document.elementsFromPoint(x, y).find((e) => e !== layer && e !== ink);
189
193
  if (el?.localName === "iframe") {
190
194
  try {
191
- const r = el.getBoundingClientRect(), dx = r.left + el.clientLeft, dy = r.top + el.clientTop;
192
- if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => [px - dx, py - dy]));
195
+ if (typeof el.contentWindow.partIn === "function") return await el.contentWindow.partIn(points.map(([px, py]) => inFrame(el, px, py)));
193
196
  } catch (err) { console.warn("partIn:", err); }
194
197
  }
195
198
  const owner = document.elementsFromPoint(x, y).find((e) => typeof e.partIn === "function");
@@ -204,7 +207,7 @@ export default async function (page) {
204
207
  const left = Math.min(...xs), top = Math.min(...ys), width = Math.max(...xs) - left, height = Math.max(...ys) - top;
205
208
  if (width < 12 && height < 12) return openNote(target(e.clientX, e.clientY), e.clientX, e.clientY);
206
209
  const t = target(left + width / 2, top + height / 2); // what is under the middle of the drawing
207
- if (t) t.area = { ...inMain(left, top), width: Math.round(width), height: Math.round(height) };
210
+ if (t) t.area = { ...inMain(left, top), width: Math.round(width / zoom()), height: Math.round(height / zoom()) };
208
211
  const inside = t && (await partIn(left + width / 2, top + height / 2, points));
209
212
  if (inside) t.part = inside;
210
213
  openNote(t, e.clientX, e.clientY, points);
@@ -193,7 +193,7 @@
193
193
  #f { position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 30; width: min(720px, 64vw);
194
194
  display: flex; align-items: center; gap: 8px; padding: 2px 0 6px; border-bottom: 1px solid rgba(0, 0, 0, 0.16); }
195
195
  #f:focus-within { border-bottom-color: rgba(0, 0, 0, 0.4); }
196
- #to { border: 0; background: none; font: inherit; font-size: 12px; color: var(--muted); }
196
+ #to { border: 0; background: none; font: inherit; font-size: 12px; color: var(--muted); outline: none; }
197
197
  #text { flex: 1; border: 0; outline: none; background: none; font: inherit; font-size: 16.5px; color: var(--text); padding: 4px 0; }
198
198
  #text::placeholder { color: var(--muted); }
199
199
  /* Slash commands: the list above the input while a command's name is typed */
@@ -49,9 +49,9 @@ export default async function (page) {
49
49
  await new Promise((ok) => setTimeout(ok, 400)); // it has slid away; a fresh frame from the shared tab
50
50
  const full = frame();
51
51
  const s = full && await picture(full, { x: 0, y: 0, w: innerWidth, h: innerHeight }).catch(() => null);
52
- const m = area.getBoundingClientRect(); // window (0, 0) in main-space px (as note/browser.js)
52
+ const m = area.getBoundingClientRect(), zoom = parseFloat(area.style.zoom) || 1; // window (0, 0) in main-space px (as note/browser.js)
53
53
  page.send({ type: "screen_reply", id, shot: s?.path || null, window: { width: innerWidth, height: innerHeight },
54
- main: { x: area.scrollLeft - m.left, y: area.scrollTop - m.top } });
54
+ main: { x: area.scrollLeft - m.left / zoom, y: area.scrollTop - m.top / zoom }, zoom });
55
55
  }
56
56
  page.on("screen_ask", ({ id, agent }) => {
57
57
  if (shared()) return answer(id);
@@ -4,8 +4,9 @@
4
4
  // (their size, scroll, panels, running apps; browser-check's headless Chrome is a fresh window of its own).
5
5
  // POST /screen/shot a png (at most MAX_BYTES): saved in data/screen, {path} back
6
6
  // event "screen_ask" {id, agent} to every open page, when an agent calls screen_see
7
- // page message screen_reply {id, shot, window: {width, height}, main: {x, y}}: the first answers
8
- // (shot null: the user said no; main: window (0, 0) in main-space px)
7
+ // page message screen_reply {id, shot, window: {width, height}, main: {x, y}, zoom}: the first answers
8
+ // (shot null: the user said no; main: window (0, 0) in main-space px; zoom: the board's,
9
+ // window px per main-space px)
9
10
  // event "screen_ask_done" {id} to every page when answered or after ASK_WAIT s, so the asks go away
10
11
  // host.api.screen isShot(path): whether a path is a picture saved here (others attach pictures by path)
11
12
  import fs from "node:fs";
@@ -70,11 +71,12 @@ export default function (pi: ExtensionAPI) {
70
71
  if (!reply) return result(`no answer within ${ASK_WAIT} s (the page may not be open, or the user is away): ask them what they see`);
71
72
  if (!isShot(reply.shot)) return result("the user didn't share their screen: ask them what they see");
72
73
  const png = fs.readFileSync(reply.shot), pw = png.readUInt32BE(16), ph = png.readUInt32BE(20); // the png's own size (IHDR)
73
- const w = reply.window ?? {}, m = reply.main ?? {}, k = w.width ? pw / w.width : 1;
74
+ const w = reply.window ?? {}, m = reply.main ?? {}, z = Number(reply.zoom) || 1, k = (w.width ? pw / w.width : 1) * z; // picture px per main px
74
75
  const mx = Math.round(m.x ?? 0), my = Math.round(m.y ?? 0);
75
76
  return result(`the user's screen right now: their window is ${w.width}x${w.height} px; the picture is the whole window, `
76
- + `${pw}x${ph} px (${+k.toFixed(3)} picture px per window px). Window (0, 0) is main-space (${mx}, ${my}): a spot in `
77
- + `the picture is main-space x = ${mx} + picture x / ${+k.toFixed(3)}, the same for y (what card_show's place uses).`, reply.shot);
77
+ + `${pw}x${ph} px${z !== 1 ? `, with the board zoomed to ${Math.round(z * 100)}%` : ""} (${+k.toFixed(3)} picture px per main-space px). `
78
+ + `Window (0, 0) is main-space (${mx}, ${my}): a spot in the picture is main-space x = ${mx} + picture x / ${+k.toFixed(3)}, `
79
+ + `the same for y (what card_show's place uses).`, reply.shot);
78
80
  },
79
81
  });
80
82
  }
@@ -15,15 +15,15 @@ const image = (src) => new Promise((ok, no) => Object.assign(new Image(), { onlo
15
15
  async function picture() { // a PNG blob of the cards, or null if there are none
16
16
  const top = document.getElementById("top"), cards = [...top.querySelectorAll("[data-card]")];
17
17
  if (!cards.length) return null;
18
- const domtoimage = await library(), at = top.getBoundingClientRect();
19
- const box = (el) => { const r = el.getBoundingClientRect(); return { x: r.left - at.left + top.scrollLeft, y: r.top - at.top + top.scrollTop, w: r.width, h: r.height }; };
18
+ const domtoimage = await library(), at = top.getBoundingClientRect(), z = parseFloat(top.style.zoom) || 1; // the board's zoom: drawn at 100%
19
+ const box = (el) => { const r = el.getBoundingClientRect(); return { x: (r.left - at.left) / z + top.scrollLeft, y: (r.top - at.top) / z + top.scrollTop, w: r.width / z, h: r.height / z }; };
20
20
  const all = cards.map(box);
21
21
  const x0 = Math.max(0, Math.min(...all.map((b) => b.x)) - MARGIN), y0 = Math.max(0, Math.min(...all.map((b) => b.y)) - MARGIN);
22
22
  const x1 = Math.max(...all.map((b) => b.x + b.w)) + MARGIN, y1 = Math.max(...all.map((b) => b.y + b.h)) + MARGIN;
23
23
  const width = Math.ceil(x1), height = Math.ceil(y1); // the whole main space up to the last card; cropped below
24
24
  const bgcolor = getComputedStyle(document.body).backgroundColor;
25
25
  const page = await image(await domtoimage.toPng(top, { width, height, bgcolor,
26
- style: { position: "static", inset: "auto", overflow: "visible", width: `${width}px`, height: `${height}px` } }));
26
+ style: { position: "static", inset: "auto", overflow: "visible", width: `${width}px`, height: `${height}px`, zoom: "1" } }));
27
27
  const canvas = Object.assign(document.createElement("canvas"), { width: Math.ceil(x1 - x0), height: Math.ceil(y1 - y0) });
28
28
  const g = canvas.getContext("2d");
29
29
  g.drawImage(page, -x0, -y0);