@chalky/chalky 0.1.4 → 0.1.6
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/README.md +16 -5
- package/agent/SYSTEM.md +36 -0
- package/agent/extensions/app/README.md +72 -0
- package/{app/extensions/ui → agent/extensions/app}/agents.js +22 -16
- package/agent/extensions/app/guide.md +5 -0
- package/agent/extensions/app/index.ts +66 -0
- package/{app/extensions/ui/skills/agent-apps → agent/extensions/app/skills/app-agents}/SKILL.md +17 -18
- package/agent/extensions/app/skills/test-your-app/SKILL.md +23 -0
- package/{app/extensions/boards → agent/extensions/board}/browser.js +30 -32
- package/{app/extensions/boards → agent/extensions/board}/guide.md +1 -1
- package/{app/extensions/boards → agent/extensions/board}/index.ts +32 -30
- package/agent/extensions/card/README.md +88 -0
- package/agent/extensions/card/blocks/Button.js +34 -0
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Column.js +2 -2
- package/agent/extensions/card/blocks/Markdown.js +37 -0
- package/{app/extensions/ui/components/Card.js → agent/extensions/card/blocks/Panel.js} +3 -3
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/Row.js +2 -2
- package/{app/extensions/ui/components → agent/extensions/card/blocks}/TextField.js +3 -3
- package/{app/extensions/ui → agent/extensions/card}/browser.js +155 -129
- package/agent/extensions/card/guide.md +13 -0
- package/agent/extensions/card/index.ts +370 -0
- package/{app/extensions/commands → agent/extensions/command}/browser.js +3 -3
- package/agent/extensions/command/guide.md +2 -0
- package/{app/extensions/commands → agent/extensions/command}/index.ts +14 -16
- package/agent/extensions/doodle/README.md +57 -0
- package/agent/extensions/doodle/browser.js +1013 -0
- package/agent/extensions/doodle/guide.md +22 -0
- package/agent/extensions/doodle/index.ts +274 -0
- package/agent/extensions/file/browser.js +182 -0
- package/agent/extensions/file/guide.md +5 -0
- package/{app/extensions/files → agent/extensions/file}/index.ts +37 -18
- package/{app/extensions/frames → agent/extensions/frame}/README.md +13 -13
- package/{app/extensions/frames → agent/extensions/frame}/browser.js +20 -18
- package/agent/extensions/frame/guide.md +3 -0
- package/agent/extensions/frame/index.ts +90 -0
- package/{app/extensions/ui_history → agent/extensions/history}/browser.js +18 -18
- package/{app/extensions/ui_history → agent/extensions/history}/index.ts +34 -33
- package/{app → agent}/extensions/mcp/browser.js +1 -1
- package/agent/extensions/mcp/guide.md +3 -0
- package/{app → agent}/extensions/mcp/index.ts +2 -4
- package/{app/extensions/login → agent/extensions/model}/browser.js +67 -67
- package/{app → agent}/extensions/model/index.ts +8 -4
- package/{app/extensions/login/index.ts → agent/extensions/model/login.ts} +47 -46
- package/agent/extensions/note/README.md +37 -0
- package/agent/extensions/note/browser.js +382 -0
- package/agent/extensions/note/guide.md +9 -0
- package/agent/extensions/note/index.ts +215 -0
- package/agent/extensions/page/browser.js +453 -0
- package/{app → agent}/extensions/page/index.ts +72 -3
- package/{app → agent}/extensions/page/page.html +102 -49
- package/agent/extensions/page/skills/interact-with-page/SKILL.md +59 -0
- package/agent/extensions/prompt/index.ts +38 -0
- package/{app → agent}/extensions/reload/index.ts +3 -3
- package/{app → agent}/extensions/screen/browser.js +10 -23
- package/{app → agent}/extensions/screen/index.ts +11 -11
- package/{app/extensions/steps → agent/extensions/step}/index.ts +1 -1
- package/{app/extensions/swarm → agent/extensions/team}/README.md +19 -19
- package/{app/extensions/swarm → agent/extensions/team}/guide.md +4 -4
- package/{app/extensions/swarm → agent/extensions/team}/index.ts +41 -66
- package/{app/extensions/versions → agent/extensions/version}/browser.js +19 -10
- package/agent/extensions/version/index.ts +205 -0
- package/{app → agent}/extensions/viewer/README.md +4 -4
- package/{app → agent}/extensions/viewer/browser.js +8 -8
- package/agent/extensions/viewer/guide.md +3 -0
- package/{app → agent}/extensions/viewer/index.ts +22 -17
- package/{app → agent}/extensions/voice/browser.js +4 -3
- package/{app → agent}/extensions/voice/index.ts +8 -6
- package/{app/extensions/ui → agent/extensions/voice}/voice.js +5 -5
- package/agent/settings.json +8 -0
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/SKILL.md +4 -4
- package/{app/extensions/ui/skills/ui-check → agent/skills/browser-check}/scripts/check.mjs +1 -1
- package/{app/extensions/ui/skills/ui-design → agent/skills/design}/SKILL.md +15 -15
- package/agent/skills/extension-map/SKILL.md +56 -0
- package/agent/skills/show-me/SKILL.md +66 -0
- package/{app → agent}/skills/write-skill/SKILL.md +1 -1
- package/bin/chalky.mjs +2 -2
- package/package.json +9 -8
- package/server.ts +313 -58
- package/app/SYSTEM.md +0 -31
- package/app/extensions/appdata/README.md +0 -10
- package/app/extensions/appdata/guide.md +0 -4
- package/app/extensions/appdata/index.ts +0 -47
- package/app/extensions/commands/guide.md +0 -3
- package/app/extensions/doodle/README.md +0 -15
- package/app/extensions/doodle/browser.js +0 -398
- package/app/extensions/doodle/guide.md +0 -6
- package/app/extensions/doodle/index.ts +0 -54
- package/app/extensions/files/browser.js +0 -96
- package/app/extensions/files/guide.md +0 -7
- package/app/extensions/frames/guide.md +0 -7
- package/app/extensions/frames/index.ts +0 -85
- package/app/extensions/mcp/guide.md +0 -5
- package/app/extensions/point/README.md +0 -22
- package/app/extensions/point/browser.js +0 -247
- package/app/extensions/point/guide.md +0 -6
- package/app/extensions/point/index.ts +0 -112
- package/app/extensions/prompt/index.ts +0 -19
- package/app/extensions/ui/README.md +0 -69
- package/app/extensions/ui/components/App.js +0 -28
- package/app/extensions/ui/components/Button.js +0 -28
- package/app/extensions/ui/components/Markdown.js +0 -37
- package/app/extensions/ui/guide.md +0 -7
- package/app/extensions/ui/index.ts +0 -291
- package/app/extensions/versions/index.ts +0 -168
- package/app/extensions/viewer/guide.md +0 -5
- package/app/mcp.json +0 -3
- package/app/settings.json +0 -3
- package/app/skills/plugin-map/SKILL.md +0 -49
- package/app/ui/apps/.keep +0 -0
- package/node_modules/xlsx/CHANGELOG.md +0 -361
- package/node_modules/xlsx/LICENSE +0 -201
- package/node_modules/xlsx/README.md +0 -41
- package/node_modules/xlsx/bin/xlsx.njs +0 -310
- package/node_modules/xlsx/bower.json +0 -22
- package/node_modules/xlsx/dist/LICENSE +0 -201
- package/node_modules/xlsx/dist/cpexcel.d.ts +0 -39
- package/node_modules/xlsx/dist/cpexcel.full.mjs +0 -1502
- package/node_modules/xlsx/dist/cpexcel.js +0 -1506
- package/node_modules/xlsx/dist/shim.min.js +0 -2
- package/node_modules/xlsx/dist/xlsx.core.min.js +0 -18
- package/node_modules/xlsx/dist/xlsx.core.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.extendscript.js +0 -28259
- package/node_modules/xlsx/dist/xlsx.full.min.js +0 -24
- package/node_modules/xlsx/dist/xlsx.full.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.mini.min.js +0 -10
- package/node_modules/xlsx/dist/xlsx.mini.min.map +0 -1
- package/node_modules/xlsx/dist/xlsx.zahl.js +0 -4
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +0 -2
- package/node_modules/xlsx/dist/zahl.d.ts +0 -4
- package/node_modules/xlsx/package.json +0 -183
- package/node_modules/xlsx/types/index.d.ts +0 -1065
- package/node_modules/xlsx/types/tsconfig.json +0 -15
- package/node_modules/xlsx/xlsx.js +0 -28103
- package/node_modules/xlsx/xlsx.mjs +0 -28228
- package/node_modules/xlsx/xlsxworker.js +0 -14
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Table.js +0 -0
- /package/{app/extensions/ui/components → agent/extensions/card/blocks}/Text.js +0 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
// The page side of the doodle extension: three tools in the palette, a marker, Text and an eraser. The marker and
|
|
2
|
+
// Text can be on together; the eraser (like any other tool) turns them off.
|
|
3
|
+
// marker drag draws on the page, over cards and inside app frames (same-origin). A press that doesn't move
|
|
4
|
+
// is an ordinary click, so double and triple clicks still select text. Typing with the pointer over the
|
|
5
|
+
// page and no text box focused puts words in handwriting there.
|
|
6
|
+
// Text (T) every click puts words in handwriting there; Enter or a click elsewhere ends them. With the
|
|
7
|
+
// marker on too, a drag draws and a click writes.
|
|
8
|
+
// eraser press or drag over a line or words (also saved ones, the agents' too: for good) to take them away whole; it also says "erase" (a page event,
|
|
9
|
+
// {x, y} in main-space px) so other tools take theirs away (frames: an unsent frame).
|
|
10
|
+
// click with the marker (Text off) works the page as usual. If there is drawing, it first becomes a step: a picture of the screen
|
|
11
|
+
// as it was when the button went down (before the click acts), cropped to the drawing, and the
|
|
12
|
+
// ink clears. The drawing goes only as the picture; typed words also go as text, exactly. Clicks
|
|
13
|
+
// are written down as words, between the steps.
|
|
14
|
+
// box a moment after the user stops drawing, a box by the last mark asks for words: Enter there sends (sendAll).
|
|
15
|
+
// Drawing again tucks it away; Esc closes it. Ctrl+Enter anywhere also sends.
|
|
16
|
+
// Enter in the message line (also with the marker off) sends the steps, the drawing still on the page and
|
|
17
|
+
// the words typed there, together: doodle_send, to the agent picked in the chat. The drawing still on the
|
|
18
|
+
// page stays there, saved, as a group (each mark with what the page can tell about it from geometry: say()).
|
|
19
|
+
// groups the shared board (the last part of this file): saved groups, the user's and the agents' marks (an agent's
|
|
20
|
+
// gesture is drawn by hand from what it names: find()), a ✓ chip per group to clear it, answered groups dim,
|
|
21
|
+
// a cleared group comes back from the chat log (hover: a ghost; click: back). Nothing clears by itself.
|
|
22
|
+
// show one switch (the eye) hides or shows every mark: doodles, words, the agents' marks, note pins, chips.
|
|
23
|
+
// boards going to another board (board/) is like a click: the drawing so far becomes a step, then "went to
|
|
24
|
+
// board N". Each step says the board it was drawn on.
|
|
25
|
+
// Things with a drag of their own keep it: a card's label (moves it), its resize corner, lists to pick from,
|
|
26
|
+
// sliders, draggable things, frames; text boxes don't (a drag that starts on one draws). Ctrl+Z takes back
|
|
27
|
+
// the last mark; Esc turns the tools off (ink stays).
|
|
28
|
+
// Pictures: the tab is shared once (the screen extension), when the marker or Text is first turned on; without
|
|
29
|
+
// it, steps go as words only. The steps so far show in the message line ("1 → ☆ Week 2 · 2 marks"): hover one for its picture, click it to see it big, ✕ drops it.
|
|
30
|
+
// Ink and words live in the main space (slot "top"), in main-space px, so they scroll with the cards.
|
|
31
|
+
export default async function (page) {
|
|
32
|
+
const area = page.slot("top");
|
|
33
|
+
const form = document.getElementById("f"), input = document.getElementById("text");
|
|
34
|
+
const SVG = "http://www.w3.org/2000/svg", INK = "#e5397a";
|
|
35
|
+
// The cursor: a marker (white edge, so it shows on dark too); its tip (3, 21) is where the line starts.
|
|
36
|
+
const PEN = `url("data:image/svg+xml,${encodeURIComponent(
|
|
37
|
+
`<svg xmlns="${SVG}" width="24" height="24" viewBox="0 0 24 24"><path d="M3 21l1.6-5.2L16.2 4.2a2.2 2.2 0 013.1 3.1L7.7 18.9z" ` +
|
|
38
|
+
`fill="#fff" stroke="#1d1d1f" stroke-width="1.4" stroke-linejoin="round"/><path d="M3 21l1.6-5.2 3.1 3.1z" fill="${INK}"/></svg>`)}") 3 21, crosshair`;
|
|
39
|
+
const RUB = `url("data:image/svg+xml,${encodeURIComponent( // the eraser cursor; its bottom-left edge (5, 20) rubs
|
|
40
|
+
`<svg xmlns="${SVG}" width="24" height="24" viewBox="0 0 24 24"><path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4l9.6-9.6a2.4 2.4 0 0 1 3.4 0` +
|
|
41
|
+
`l5.6 5.6a2.4 2.4 0 0 1 0 3.4L13 21z" fill="#fff" stroke="#1d1d1f" stroke-width="1.4" stroke-linejoin="round"/>` +
|
|
42
|
+
`<path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4L7.5 8.5l9 9L13 21z" fill="${INK}"/></svg>`)}") 5 20, crosshair`;
|
|
43
|
+
const CURSORS = `
|
|
44
|
+
.doodling, .doodling * { cursor: ${PEN}; }
|
|
45
|
+
.doodling :is(button, a, select, summary, label, [onclick], [role="button"], [role="tab"], .card-label) { cursor: pointer; }
|
|
46
|
+
.doodling :is(input, textarea, [contenteditable="true"]) { cursor: text; }
|
|
47
|
+
.doodling .card-resize { cursor: nwse-resize; }
|
|
48
|
+
.doodle-text:not(.doodling), .doodle-text:not(.doodling) * { cursor: text !important; }
|
|
49
|
+
.doodle-erase, .doodle-erase * { cursor: ${RUB} !important; }`;
|
|
50
|
+
const style = document.createElement("style");
|
|
51
|
+
style.textContent = CURSORS + `
|
|
52
|
+
#top:is(.doodling, .doodle-text, .doodle-erase) { z-index: 11; } /* above the messages panel (10), so lines can go there too */
|
|
53
|
+
#top > svg.doodle-ink { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; z-index: 100001; pointer-events: none; }
|
|
54
|
+
#top > svg.doodle-ink path { fill: none; stroke: ${INK}; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
55
|
+
#top > .doodle-note { position: absolute; z-index: 100001; font: 20px/1.2 "Segoe Print", "Bradley Hand", "Comic Sans MS", cursive; color: ${INK};
|
|
56
|
+
white-space: pre; outline: none; pointer-events: none; padding: 0 4px; min-width: 8px; }
|
|
57
|
+
#top > .doodle-note:empty::before { content: "type here…"; opacity: .5; }
|
|
58
|
+
#top > .doodle-note[contenteditable="true"] { pointer-events: auto; background: rgba(229, 57, 122, .1); border-radius: 6px; caret-color: ${INK}; }
|
|
59
|
+
#f .doodle-strip { display: flex; align-items: center; gap: 4px; flex: none; max-width: 45%; overflow-x: auto; }
|
|
60
|
+
#f .doodle-strip:empty { display: none; }
|
|
61
|
+
#f .doodle-chip { position: relative; flex: none; min-width: 24px; height: 24px; padding: 0 7px; box-sizing: border-box; border-radius: 7px;
|
|
62
|
+
display: grid; place-items: center; font-size: 12px; font-weight: 600; color: ${INK}; border: 1.5px solid ${INK};
|
|
63
|
+
background: rgba(229, 57, 122, .08); cursor: default; }
|
|
64
|
+
#f .doodle-chip.now { border-style: dashed; background: none; font-weight: 500; }
|
|
65
|
+
#f .doodle-chip .x { position: absolute; right: -6px; top: -6px; width: 14px; height: 14px; border-radius: 50%; background: var(--text);
|
|
66
|
+
color: #fff; font-size: 9px; line-height: 14px; text-align: center; display: none; cursor: pointer; }
|
|
67
|
+
#f .doodle-chip:hover .x { display: block; }
|
|
68
|
+
#f .doodle-click { flex: none; font-size: 12px; color: var(--muted); white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
|
69
|
+
.doodle-peek { position: fixed; z-index: 100; max-width: 360px; padding: 6px; border-radius: 12px; background: var(--glass-strong, #fff);
|
|
70
|
+
border: 1px solid var(--edge, #eee); box-shadow: 0 8px 30px rgba(0, 0, 0, .12); font-size: 12px; color: var(--muted); pointer-events: none; }
|
|
71
|
+
.doodle-peek img { display: block; max-width: 100%; max-height: 220px; border-radius: 6px; margin-bottom: 4px; }
|
|
72
|
+
#f .doodle-chip:not(.now) { cursor: zoom-in; }
|
|
73
|
+
.doodle-big { position: fixed; inset: 0; z-index: 200; display: grid; place-content: center; justify-items: center; gap: 10px;
|
|
74
|
+
background: rgba(0, 0, 0, .55); cursor: zoom-out; color: #fff; font-size: 13px; }
|
|
75
|
+
.doodle-big div { padding: 4px 12px; border-radius: 999px; background: rgba(0, 0, 0, .6); }
|
|
76
|
+
.doodle-big img { max-width: 92vw; max-height: 84vh; border-radius: 10px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); background: #fff; }
|
|
77
|
+
.doodle-group { position: absolute; left: 0; top: 0; width: 0; height: 0; overflow: visible; z-index: 100000; pointer-events: none; transition: opacity .8s; }
|
|
78
|
+
.doodle-group svg { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; }
|
|
79
|
+
.doodle-group path { fill: none; stroke: var(--ink); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
80
|
+
.doodle-group .words { position: absolute; font: 20px/1.2 "Segoe Print", "Bradley Hand", "Comic Sans MS", cursive; color: var(--ink);
|
|
81
|
+
white-space: pre; padding: 0 4px; }
|
|
82
|
+
.doodle-group .words.agent { font-size: 17px; background: rgba(255, 255, 255, .7); border-radius: 6px; }
|
|
83
|
+
.doodle-group .chip { position: absolute; width: 20px; height: 20px; border-radius: 50%; display: grid; place-items: center; pointer-events: auto;
|
|
84
|
+
font-size: 12px; color: #fff; background: #1d1d1f; box-shadow: 0 1px 4px rgba(0, 0, 0, .25); cursor: pointer; opacity: .55; }
|
|
85
|
+
.doodle-group .chip:hover { opacity: 1; }
|
|
86
|
+
.doodle-group.doodle-fixed { position: fixed; z-index: 100004; }
|
|
87
|
+
.doodle-group.dim { opacity: .35; }
|
|
88
|
+
.doodle-group.gone { opacity: 0; }
|
|
89
|
+
.doodle-group.ghost { opacity: .4; }
|
|
90
|
+
.doodle-group.ghost path { stroke-dasharray: 6 5; }
|
|
91
|
+
.doodle-group.ghost .chip { display: none; }
|
|
92
|
+
body.marks-hidden :is(.doodle-group, svg.doodle-ink, .doodle-note, .note-pin) { display: none !important; }
|
|
93
|
+
#top > .doodle-ask { position: absolute; z-index: 100003; width: 300px; box-sizing: border-box; display: flex; gap: 6px; align-items: center; padding: 6px 6px 6px 12px;
|
|
94
|
+
border-radius: 12px; background: var(--glass-strong, #fff); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur);
|
|
95
|
+
border: 1px solid ${INK}; box-shadow: 0 8px 30px rgba(0, 0, 0, .12); animation: doodle-ask .25s ease-out; cursor: auto; }
|
|
96
|
+
#top > .doodle-ask input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; font: inherit; font-size: 14px; color: var(--text, #1d1d1f); }
|
|
97
|
+
#top > .doodle-ask button { width: 28px; height: 28px; border-radius: 50%; border: 0; background: ${INK}; color: #fff; font-size: 15px; cursor: pointer; }
|
|
98
|
+
@keyframes doodle-ask { from { opacity: 0; transform: translateY(4px); } }
|
|
99
|
+
@media (prefers-reduced-motion: reduce) { #top > .doodle-ask { animation: none; } }
|
|
100
|
+
#log [data-raw].doodle-back, #log .note.doodle-back { cursor: pointer; text-decoration: underline dotted; }`;
|
|
101
|
+
document.head.append(style);
|
|
102
|
+
|
|
103
|
+
const button = document.createElement("button");
|
|
104
|
+
button.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 17c3-6 5-6 7-2s4 4 7-3 3-5 4-4"/></svg>`;
|
|
105
|
+
button.title = "Doodle: drag to draw, type to write, click to use the page; then Enter in the message line sends it all (Esc to stop)";
|
|
106
|
+
const textButton = document.createElement("button");
|
|
107
|
+
textButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7V5h14v2M12 5v14M9 19h6"/></svg>`;
|
|
108
|
+
textButton.title = "Text: click where the words go (Enter to finish); sent with the doodles, Enter in the message line (Esc to stop)";
|
|
109
|
+
const eraseButton = document.createElement("button");
|
|
110
|
+
eraseButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m7 21-4.3-4.3a2.4 2.4 0 0 1 0-3.4l9.6-9.6a2.4 2.4 0 0 1 3.4 0` +
|
|
111
|
+
`l5.6 5.6a2.4 2.4 0 0 1 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>`;
|
|
112
|
+
eraseButton.title = "Eraser: press or drag over lines, words or unsent frames to take them away (Esc to stop)";
|
|
113
|
+
const showButton = document.createElement("button"); // one switch for every mark: doodles, words, the agents' marks, note pins
|
|
114
|
+
const EYE = `<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>`;
|
|
115
|
+
const showMarks = (on) => {
|
|
116
|
+
document.body.classList.toggle("marks-hidden", !on);
|
|
117
|
+
showButton.classList.toggle("on", !on);
|
|
118
|
+
showButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${EYE}${on ? "" : `<path d="M3 3l18 18"/>`}</svg>`;
|
|
119
|
+
showButton.title = on ? "Hide all marks (doodles, words, notes)" : "Show all marks again";
|
|
120
|
+
try { localStorage.setItem("chalky-marks-hidden", on ? "" : "1"); } catch {}
|
|
121
|
+
};
|
|
122
|
+
let hiddenAtStart = false;
|
|
123
|
+
try { hiddenAtStart = !!localStorage.getItem("chalky-marks-hidden"); } catch {}
|
|
124
|
+
showMarks(!hiddenAtStart);
|
|
125
|
+
showButton.onclick = () => showMarks(document.body.classList.contains("marks-hidden"));
|
|
126
|
+
// names an agent's pen can point at (doodle_mark on: {element: "pen"}): the page's own controls
|
|
127
|
+
Object.assign(button.dataset, { name: "pen" }); Object.assign(textButton.dataset, { name: "text" });
|
|
128
|
+
Object.assign(eraseButton.dataset, { name: "eraser" }); Object.assign(showButton.dataset, { name: "show-marks" });
|
|
129
|
+
page.slot("status").append(button, textButton, eraseButton, showButton);
|
|
130
|
+
const strip = Object.assign(document.createElement("div"), { className: "doodle-strip" });
|
|
131
|
+
form.insertBefore(strip, input);
|
|
132
|
+
const peek = Object.assign(document.createElement("div"), { className: "doodle-peek", hidden: true });
|
|
133
|
+
const big = Object.assign(document.createElement("div"), { className: "doodle-big", hidden: true }); // a step's picture, big
|
|
134
|
+
big.onclick = () => { big.hidden = true; };
|
|
135
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") big.hidden = true; });
|
|
136
|
+
document.body.append(peek, big);
|
|
137
|
+
|
|
138
|
+
let drawing = false, texting = false, erasing = false, on = false; // which tools are on; on: any of them
|
|
139
|
+
const docs = new Set([document]); // this page and every App frame's page: they all get the cursor
|
|
140
|
+
const classes = (el) => {
|
|
141
|
+
el.classList.toggle("doodling", drawing);
|
|
142
|
+
el.classList.toggle("doodle-text", texting);
|
|
143
|
+
el.classList.toggle("doodle-erase", erasing);
|
|
144
|
+
};
|
|
145
|
+
const set = (tool, v) => { // tool: "doodle" | "text" | "erase"
|
|
146
|
+
if (tool === "doodle") drawing = v; else if (tool === "text") texting = v; else erasing = v;
|
|
147
|
+
on = drawing || texting || erasing;
|
|
148
|
+
button.classList.toggle("on", drawing);
|
|
149
|
+
textButton.classList.toggle("on", texting);
|
|
150
|
+
eraseButton.classList.toggle("on", erasing);
|
|
151
|
+
classes(area);
|
|
152
|
+
for (const d of docs) if (d !== document && d.documentElement) classes(d.documentElement);
|
|
153
|
+
if (v) {
|
|
154
|
+
if (document.body.classList.contains("marks-hidden")) showMarks(true); // drawing on a hidden layer would be confusing
|
|
155
|
+
if (tool !== "erase") share();
|
|
156
|
+
document.dispatchEvent(new CustomEvent("tool-on", { detail: tool }));
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
const allOff = () => { for (const t of ["doodle", "text", "erase"]) set(t, false); };
|
|
160
|
+
button.onclick = () => set("doodle", !drawing);
|
|
161
|
+
textButton.onclick = () => set("text", !texting);
|
|
162
|
+
eraseButton.onclick = () => set("erase", !erasing);
|
|
163
|
+
document.addEventListener("tool-on", (e) => { // one tool at a time, except the marker and Text, which work together
|
|
164
|
+
const t = e.detail, pair = t === "doodle" || t === "text";
|
|
165
|
+
if (drawing && t !== "doodle" && !pair) set("doodle", false);
|
|
166
|
+
if (texting && t !== "text" && !pair) set("text", false);
|
|
167
|
+
if (erasing && t !== "erase") set("erase", false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---- pictures: the tab, shared once by the screen extension (none without it: steps go as words) ----
|
|
171
|
+
let screen = null;
|
|
172
|
+
page.use("screen").then((s) => { screen = s; });
|
|
173
|
+
const share = () => screen?.share();
|
|
174
|
+
const frame = () => screen?.frame() ?? null; // the screen right now, as a canvas (null without a shared tab)
|
|
175
|
+
const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
|
|
176
|
+
|
|
177
|
+
// ---- where things are ----
|
|
178
|
+
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 };
|
|
181
|
+
};
|
|
182
|
+
const inWindow = (p) => {
|
|
183
|
+
const m = area.getBoundingClientRect();
|
|
184
|
+
return { x: p.x + m.left - area.scrollLeft, y: p.y + m.top - area.scrollTop };
|
|
185
|
+
};
|
|
186
|
+
const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
|
|
187
|
+
|
|
188
|
+
// ---- marks: lines and words ----
|
|
189
|
+
const ink = document.createElementNS(SVG, "svg"); // in the main space only while it has marks (#top:empty shows the welcome)
|
|
190
|
+
ink.classList.add("doodle-ink");
|
|
191
|
+
let marks = []; // [{el, points (main px), text (words only)}], oldest first
|
|
192
|
+
const show = () => { if (marks.some((m) => m.el.localName === "path" || m.el.localName === "g")) area.append(ink); else ink.remove(); render(); if (!marks.length) hideAsk(); };
|
|
193
|
+
const f = (n) => n.toFixed(1);
|
|
194
|
+
const smooth = (pts) => { // through the midpoints, so a quick line isn't a row of corners
|
|
195
|
+
let d = `M${f(pts[0].x)} ${f(pts[0].y)}`;
|
|
196
|
+
for (let i = 1; i < pts.length - 1; i++) d += ` Q${f(pts[i].x)} ${f(pts[i].y)} ${f((pts[i].x + pts[i + 1].x) / 2)} ${f((pts[i].y + pts[i + 1].y) / 2)}`;
|
|
197
|
+
return d + ` L${f(pts.at(-1).x)} ${f(pts.at(-1).y)}`;
|
|
198
|
+
};
|
|
199
|
+
function note(p, first) { // words in handwriting at p (main px)
|
|
200
|
+
const n = Object.assign(document.createElement("div"), { className: "doodle-note", contentEditable: "true", textContent: first });
|
|
201
|
+
Object.assign(n.style, { left: `${p.x}px`, top: `${p.y - 14}px` });
|
|
202
|
+
area.append(n);
|
|
203
|
+
n.focus();
|
|
204
|
+
getSelection().selectAllChildren(n);
|
|
205
|
+
getSelection().collapseToEnd();
|
|
206
|
+
let done = false;
|
|
207
|
+
const finish = () => {
|
|
208
|
+
if (done) return;
|
|
209
|
+
done = true;
|
|
210
|
+
n.contentEditable = "false";
|
|
211
|
+
const t = n.textContent.trim();
|
|
212
|
+
if (!t) { n.remove(); return; }
|
|
213
|
+
marks.push({ el: n, points: [p, { x: p.x + n.offsetWidth, y: p.y + n.offsetHeight - 14 }], text: t });
|
|
214
|
+
show();
|
|
215
|
+
later();
|
|
216
|
+
};
|
|
217
|
+
n.onkeydown = (e) => {
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); n.blur(); }
|
|
220
|
+
if (e.key === "Escape") { n.textContent = ""; n.blur(); }
|
|
221
|
+
};
|
|
222
|
+
n.onblur = finish;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- erasing: a mark goes whole when the eraser passes over it ----
|
|
226
|
+
function eraseAt(p) { // p: main px
|
|
227
|
+
const left = marks.filter((m) => {
|
|
228
|
+
if (m.text) { // words: their box
|
|
229
|
+
const x = m.el.offsetLeft, y = m.el.offsetTop;
|
|
230
|
+
return !(p.x >= x - 6 && p.x <= x + m.el.offsetWidth + 6 && p.y >= y - 6 && p.y <= y + m.el.offsetHeight + 6);
|
|
231
|
+
}
|
|
232
|
+
return !m.points.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12); // a line: near any of its points
|
|
233
|
+
});
|
|
234
|
+
if (left.length !== marks.length) {
|
|
235
|
+
for (const m of marks) if (!left.includes(m)) m.el.remove();
|
|
236
|
+
marks = left;
|
|
237
|
+
show();
|
|
238
|
+
}
|
|
239
|
+
for (const [id, m] of Object.entries(saved.marks)) { // saved marks on this board, not cleared
|
|
240
|
+
const g = saved.groups[m.group];
|
|
241
|
+
if (erased.has(id) || !g || g.state === "cleared" || String(m.board) !== board()) continue;
|
|
242
|
+
const b = drawn[id];
|
|
243
|
+
if (b?.fixed) continue; // on the page's own controls (window px): ✓ or the agent takes those away
|
|
244
|
+
const pts = b?.pts ?? m.points; // the user's marks: where they are drawn now (moved with their anchor)
|
|
245
|
+
const hit = pts ? (m.kind === "words" ? inBox(boxOf(pts), p, 6) : pts.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12))
|
|
246
|
+
: b && inBox(b, p, 4);
|
|
247
|
+
if (hit) { erased.add(id); page.send({ type: "doodle_erase", id }); }
|
|
248
|
+
}
|
|
249
|
+
document.dispatchEvent(new CustomEvent("erase", { detail: p })); // other tools' things (frames: unsent frames)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- steps ----
|
|
253
|
+
let steps = [], first = []; // steps: [{marks: [text], then: [click], shot: {path, url} | null, ready}]
|
|
254
|
+
let grabbed = null; // the screen when the last button went down (a click may end a step with it)
|
|
255
|
+
function bounds(pts) { // window px, with room around the drawing, inside the window
|
|
256
|
+
const w = pts.map(inWindow), pad = 60;
|
|
257
|
+
let x0 = Math.min(...w.map((p) => p.x)) - pad, x1 = Math.max(...w.map((p) => p.x)) + pad;
|
|
258
|
+
let y0 = Math.min(...w.map((p) => p.y)) - pad, y1 = Math.max(...w.map((p) => p.y)) + pad;
|
|
259
|
+
if (x1 - x0 < 480) { const c = (x0 + x1) / 2; x0 = c - 240; x1 = c + 240; }
|
|
260
|
+
if (y1 - y0 < 300) { const c = (y0 + y1) / 2; y0 = c - 150; y1 = c + 150; }
|
|
261
|
+
x0 = Math.max(0, x0); y0 = Math.max(0, y0); x1 = Math.min(innerWidth, x1); y1 = Math.min(innerHeight, y1);
|
|
262
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
263
|
+
}
|
|
264
|
+
function keep(full) { // the marks on the page become a step (full: the screen to crop its picture from)
|
|
265
|
+
const box = marks.length ? bounds(marks.flatMap((m) => m.points)) : { x: 0, y: 0, w: innerWidth, h: innerHeight };
|
|
266
|
+
const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
|
|
267
|
+
const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
|
|
268
|
+
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
|
+
step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
|
|
271
|
+
steps.push(step);
|
|
272
|
+
for (const m of marks) m.el.remove();
|
|
273
|
+
marks = [];
|
|
274
|
+
show();
|
|
275
|
+
}
|
|
276
|
+
// going to another board (board/, just before it changes) is like a click: the drawing so far becomes a step
|
|
277
|
+
document.addEventListener("board", (e) => {
|
|
278
|
+
if (!marks.length && !steps.length && !first.length) return; // no doodling going on
|
|
279
|
+
if (marks.length) keep(frame());
|
|
280
|
+
(steps.length ? steps.at(-1).then : first).push(`went to board ${e.detail.to}`);
|
|
281
|
+
render();
|
|
282
|
+
});
|
|
283
|
+
function clicked(t, card) { // a click, as words
|
|
284
|
+
const label = short(t.getAttribute("aria-label") || t.title || t.textContent || t.value || t.localName, 30);
|
|
285
|
+
const row = t.closest("tr, li");
|
|
286
|
+
const rowText = row && row !== t ? short((row.innerText || row.textContent).replace(t.innerText || t.textContent, ""), 30) : "";
|
|
287
|
+
return `clicked "${label}"${rowText ? ` on the "${rowText}" row` : ""}${card ? ` (card ${card})` : ""}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- presses: this page and every App frame's page ----
|
|
291
|
+
// Text boxes draw too (a click still focuses them, a double click still selects a word): circling a notes box
|
|
292
|
+
// is a common thing to draw.
|
|
293
|
+
const OWN_DRAG = ".card-label, .card-resize, select, input[type='range'], [draggable='true'], .frame, .doodle-note";
|
|
294
|
+
const CLICKABLE = "button, a, summary, select, label, [onclick], [role='button'], [role='tab'], [role='checkbox'], input[type='checkbox'], input[type='radio']";
|
|
295
|
+
let press = null, line = null, swallow = false, over = null; // over: the pointer, in window px, while it is over the page
|
|
296
|
+
function hook(win, frameEl) {
|
|
297
|
+
const doc = win.document;
|
|
298
|
+
if (frameEl) {
|
|
299
|
+
if (docs.has(doc)) return;
|
|
300
|
+
docs.add(doc);
|
|
301
|
+
const s = doc.createElement("style");
|
|
302
|
+
s.textContent = CURSORS;
|
|
303
|
+
doc.head.append(s);
|
|
304
|
+
classes(doc.documentElement);
|
|
305
|
+
}
|
|
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 };
|
|
310
|
+
};
|
|
311
|
+
const root = frameEl ? doc : area;
|
|
312
|
+
root.addEventListener("pointerdown", (e) => {
|
|
313
|
+
if (!on || e.button !== 0 || e.target.closest?.(".doodle-ask")) return;
|
|
314
|
+
hideAsk();
|
|
315
|
+
const o = offset();
|
|
316
|
+
if (erasing) { // the press is for the eraser, not for what's under it
|
|
317
|
+
e.preventDefault();
|
|
318
|
+
press = { erase: true, win };
|
|
319
|
+
eraseAt(inMain(e.clientX + o.x, e.clientY + o.y));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
grabbed = marks.length ? frame() : null; // before the click acts
|
|
323
|
+
if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
|
|
324
|
+
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
|
+
}, true);
|
|
327
|
+
win.addEventListener("pointermove", (e) => {
|
|
328
|
+
const o = offset(), x = e.clientX + o.x, y = e.clientY + o.y;
|
|
329
|
+
over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
|
|
330
|
+
if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
|
|
331
|
+
if (!press || press.win !== win || !drawing) return;
|
|
332
|
+
if (!line) {
|
|
333
|
+
if (Math.hypot(x - press.x, y - press.y) < 6) return; // not yet a line: may still be a click
|
|
334
|
+
line = { el: document.createElementNS(SVG, "g"), points: [inMain(press.x, press.y)] };
|
|
335
|
+
line.el.append(document.createElementNS(SVG, "path"));
|
|
336
|
+
ink.append(line.el);
|
|
337
|
+
marks.push(line);
|
|
338
|
+
show();
|
|
339
|
+
try { press.target.setPointerCapture(press.id); } catch {}
|
|
340
|
+
const active = press.win.document.activeElement; // a text box the press focused: typing now writes words, not into it
|
|
341
|
+
if (active?.matches?.("input, textarea, [contenteditable='true']")) active.blur();
|
|
342
|
+
}
|
|
343
|
+
win.getSelection().removeAllRanges(); // the first few px may have started a text selection
|
|
344
|
+
const p = inMain(x, y), last = line.points.at(-1);
|
|
345
|
+
if (Math.hypot(p.x - last.x, p.y - last.y) < 1.5) return;
|
|
346
|
+
line.points.push(p);
|
|
347
|
+
line.el.firstChild.setAttribute("d", smooth(line.points));
|
|
348
|
+
});
|
|
349
|
+
win.addEventListener("pointerup", () => {
|
|
350
|
+
if (press?.erase) { swallow = true; setTimeout(() => { swallow = false; }); } // the eraser's press isn't a click
|
|
351
|
+
if (press?.text && !line) { // Text: words where the press was (a press that drew a line was for the marker)
|
|
352
|
+
note(inMain(press.x, press.y), "");
|
|
353
|
+
swallow = true;
|
|
354
|
+
setTimeout(() => { swallow = false; });
|
|
355
|
+
}
|
|
356
|
+
if (line) {
|
|
357
|
+
if (line.points.length < 3) { marks.pop(); line.el.remove(); }
|
|
358
|
+
swallow = true; // the click that ends a line isn't a click
|
|
359
|
+
setTimeout(() => { swallow = false; });
|
|
360
|
+
show();
|
|
361
|
+
later();
|
|
362
|
+
}
|
|
363
|
+
press = line = null;
|
|
364
|
+
});
|
|
365
|
+
root.addEventListener("click", (e) => {
|
|
366
|
+
if (swallow) { e.stopPropagation(); e.preventDefault(); swallow = false; return; }
|
|
367
|
+
const t = on && e.target.closest?.(CLICKABLE);
|
|
368
|
+
if (!t || t.closest(".doodle-note, .doodle-ask")) return; // the box's ↑ is ours, not a click on the page
|
|
369
|
+
if (marks.length) keep(grabbed); // the drawing so far, as the screen was when the button went down
|
|
370
|
+
(steps.length ? steps.at(-1).then : first).push(clicked(t, (frameEl || t).closest("[data-card]")?.dataset.card));
|
|
371
|
+
render();
|
|
372
|
+
}, true);
|
|
373
|
+
root.addEventListener("dragstart", (e) => { if (on) e.preventDefault(); }, true); // links and pictures don't get dragged away
|
|
374
|
+
doc.addEventListener("keydown", onKey);
|
|
375
|
+
}
|
|
376
|
+
hook(window, null);
|
|
377
|
+
const hookFrame = (fr) => {
|
|
378
|
+
const go = () => { try { if (fr.contentDocument?.readyState !== "loading") hook(fr.contentWindow, fr); } catch {} }; // other origins: not ours
|
|
379
|
+
fr.addEventListener("load", go);
|
|
380
|
+
go();
|
|
381
|
+
};
|
|
382
|
+
area.querySelectorAll("iframe").forEach(hookFrame);
|
|
383
|
+
new MutationObserver((list) => {
|
|
384
|
+
for (const r of list) for (const n of r.addedNodes) {
|
|
385
|
+
if (n.localName === "iframe") hookFrame(n);
|
|
386
|
+
else n.querySelectorAll?.("iframe").forEach(hookFrame);
|
|
387
|
+
}
|
|
388
|
+
}).observe(area, { childList: true, subtree: true });
|
|
389
|
+
|
|
390
|
+
function onKey(e) {
|
|
391
|
+
if (e.target.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
|
392
|
+
if (e.key === "Escape" && on) { allOff(); return; }
|
|
393
|
+
if (!on || erasing) return;
|
|
394
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
|
395
|
+
const m = marks.pop();
|
|
396
|
+
if (m) { m.el.remove(); show(); later(); e.preventDefault(); }
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey && over) {
|
|
400
|
+
e.preventDefault();
|
|
401
|
+
note(inMain(over.x, over.y), e.key);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- the strip in the message line ----
|
|
406
|
+
function render() {
|
|
407
|
+
strip.replaceChildren();
|
|
408
|
+
steps.forEach((s, i) => {
|
|
409
|
+
const chip = Object.assign(document.createElement("span"), { className: "doodle-chip", textContent: i + 1 });
|
|
410
|
+
const x = Object.assign(document.createElement("span"), { className: "x", textContent: "✕", title: "Drop this step" });
|
|
411
|
+
x.onclick = () => { peek.hidden = true; steps.splice(i, 1); render(); };
|
|
412
|
+
chip.append(x);
|
|
413
|
+
const words = s.words.map((w) => `"${w}"`).join(" ");
|
|
414
|
+
chip.title = s.shot ? "Click to see it big" : "No picture: the tab isn't shared";
|
|
415
|
+
chip.onmouseenter = () => { // a small preview above the chip
|
|
416
|
+
peek.replaceChildren();
|
|
417
|
+
const place = () => {
|
|
418
|
+
const r = chip.getBoundingClientRect(), p = peek.getBoundingClientRect();
|
|
419
|
+
Object.assign(peek.style, { left: `${Math.max(8, Math.min(innerWidth - p.width - 8, r.left))}px`, top: `${r.top - p.height - 10}px` });
|
|
420
|
+
};
|
|
421
|
+
if (s.shot?.url) peek.append(Object.assign(document.createElement("img"), { src: s.shot.url, onload: place }));
|
|
422
|
+
peek.append(words || (s.shot ? "" : "no picture (the tab isn't shared)"));
|
|
423
|
+
peek.hidden = false;
|
|
424
|
+
place();
|
|
425
|
+
};
|
|
426
|
+
chip.onmouseleave = () => { peek.hidden = true; };
|
|
427
|
+
chip.onclick = (e) => { // the picture big, over the page; a click or Esc closes it
|
|
428
|
+
if (e.target === x || !s.shot?.url) return;
|
|
429
|
+
peek.hidden = true;
|
|
430
|
+
big.replaceChildren(Object.assign(document.createElement("img"), { src: s.shot.url }),
|
|
431
|
+
Object.assign(document.createElement("div"), { textContent: `Step ${i + 1}${words ? ` · ${words}` : ""}` }));
|
|
432
|
+
big.hidden = false;
|
|
433
|
+
};
|
|
434
|
+
strip.append(chip);
|
|
435
|
+
for (const c of s.then) strip.append(Object.assign(document.createElement("span"), { className: "doodle-click", textContent: `→ ${c.replace(/^clicked "([^"]*)".*/, "$1")}`, title: c }));
|
|
436
|
+
});
|
|
437
|
+
if (marks.length) strip.append(Object.assign(document.createElement("span"), { className: "doodle-chip now", textContent: `${marks.length} mark${marks.length > 1 ? "s" : ""}` }));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- send: Enter in the message line takes the doodles along (the page's own send is for plain words) ----
|
|
441
|
+
document.addEventListener("submit", (e) => {
|
|
442
|
+
if (e.target !== form) return;
|
|
443
|
+
if (!steps.length && !marks.length) { if (input.value.trim()) page.send({ type: "doodle_dim" }); return; } // plain words: answered groups step back
|
|
444
|
+
e.preventDefault();
|
|
445
|
+
e.stopPropagation();
|
|
446
|
+
const text = input.value;
|
|
447
|
+
input.value = "";
|
|
448
|
+
sendAll(text);
|
|
449
|
+
}, true);
|
|
450
|
+
// Ctrl+Enter anywhere sends the doodles (with the box's words, else the message line's)
|
|
451
|
+
document.addEventListener("keydown", (e) => {
|
|
452
|
+
if (!((e.ctrlKey || e.metaKey) && e.key === "Enter") || (!steps.length && !marks.length)) return;
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
const text = ask?.querySelector("input").value || input.value;
|
|
455
|
+
input.value = "";
|
|
456
|
+
sendAll(text);
|
|
457
|
+
});
|
|
458
|
+
// Held marks: a copy of what was drawn stays on the page from Enter until the saved group is drawn (the step's picture
|
|
459
|
+
// uploads first), so the drawing doesn't blink out and back
|
|
460
|
+
let held = [];
|
|
461
|
+
function hold() {
|
|
462
|
+
drop();
|
|
463
|
+
if (marks.some((m) => !m.text)) held.push(area.appendChild(ink.cloneNode(true)));
|
|
464
|
+
for (const m of marks) if (m.text) held.push(area.appendChild(m.el.cloneNode(true)));
|
|
465
|
+
const these = held;
|
|
466
|
+
setTimeout(() => { if (held === these) drop(); }, 15000); // no answer from the server: don't leave them forever
|
|
467
|
+
}
|
|
468
|
+
function drop() { for (const el of held) el.remove(); held = []; }
|
|
469
|
+
async function sendAll(text) {
|
|
470
|
+
hideAsk();
|
|
471
|
+
if (marks.length) hold();
|
|
472
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
473
|
+
const kept = marks.map((m) => ({ kind: m.text ? "words" : "line", points: m.points.map((q) => ({ x: r1(q.x), y: r1(q.y) })), ...(m.text && { text: m.text }) }));
|
|
474
|
+
for (const m of kept) Object.assign(m, say(m)); // before keep() takes them off the page
|
|
475
|
+
if (marks.length) keep(frame());
|
|
476
|
+
else if (steps.at(-1).then.length) keep(frame()); // clicked after the last step: how the screen looks now
|
|
477
|
+
const sent = steps;
|
|
478
|
+
steps = [];
|
|
479
|
+
const before = first;
|
|
480
|
+
first = [];
|
|
481
|
+
render();
|
|
482
|
+
await Promise.all(sent.map((s) => s.ready));
|
|
483
|
+
page.send({ type: "doodle_send", to: document.getElementById("to")?.value, text, first: before, marks: kept, board: board(),
|
|
484
|
+
steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then, board: s.board })) });
|
|
485
|
+
allOff();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---- the box: a moment after the user stops drawing, a box by the last mark asks for words; Enter there sends.
|
|
489
|
+
// Drawing or writing again tucks it away (it comes back after the next pause); Esc closes it, the marks stay. ----
|
|
490
|
+
let ask = null, askTimer = null;
|
|
491
|
+
function later() { clearTimeout(askTimer); if (marks.length) askTimer = setTimeout(showAsk, 1100); }
|
|
492
|
+
function hideAsk() { clearTimeout(askTimer); ask?.remove(); ask = null; }
|
|
493
|
+
function showAsk() {
|
|
494
|
+
if (!marks.length || document.querySelector(".doodle-note[contenteditable='true']")) return; // still writing words
|
|
495
|
+
hideAsk();
|
|
496
|
+
const last = marks.at(-1).points.at(-1), to = document.getElementById("to")?.value || "agent1";
|
|
497
|
+
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
|
+
Object.assign(ask.style, { left: `${Math.min(last.x + 16, room.x)}px`, top: `${Math.min(last.y + 12, room.y)}px` });
|
|
500
|
+
const field = Object.assign(document.createElement("input"), { placeholder: `Say something… (Enter sends to ${to})`, autocomplete: "off" });
|
|
501
|
+
field.setAttribute("aria-label", "Words to send with your marks");
|
|
502
|
+
const go = Object.assign(document.createElement("button"), { type: "submit", textContent: "↑", title: `Send to ${to} (Enter)` });
|
|
503
|
+
ask.append(field, go);
|
|
504
|
+
ask.onsubmit = (e) => { e.preventDefault(); sendAll(field.value); };
|
|
505
|
+
field.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") hideAsk(); };
|
|
506
|
+
for (const t of ["pointerdown", "click"]) ask.addEventListener(t, (e) => e.stopPropagation());
|
|
507
|
+
area.append(ask);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---- the shared board: saved groups (the user's marks after Enter, the agents' marks), from the server ----
|
|
511
|
+
// A group's marks sit in one container in the main space (data-board: the board extension puts it on its board),
|
|
512
|
+
// in main px, so they scroll with the cards. The agents' marks are drawn from what they are on, found again on
|
|
513
|
+
// every layout, so they follow a card that moves; the box found when drawn is the fallback.
|
|
514
|
+
const board = () => document.body.dataset.board || "1";
|
|
515
|
+
const INK_OF = (by) => by === "user" ? INK : colour(by);
|
|
516
|
+
// the page's colour per agent (page.html), so an agent's ink matches its name in the chat
|
|
517
|
+
const colour = (a) => `hsl(${([...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 997, 7) * 137) % 360} 65% 45%)`;
|
|
518
|
+
let saved = { groups: {}, marks: {} };
|
|
519
|
+
const erased = new Set(); // sent to the server, not yet gone
|
|
520
|
+
const drawn = {}; // mark id -> the box it covers now (main px)
|
|
521
|
+
const containers = {}; // group id -> its element
|
|
522
|
+
const boxOf = (pts) => { const xs = pts.map((q) => q.x), ys = pts.map((q) => q.y), x = Math.min(...xs), y = Math.min(...ys);
|
|
523
|
+
return { x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y }; };
|
|
524
|
+
const inBox = (b, q, pad = 0) => q.x >= b.x - pad && q.x <= b.x + b.w + pad && q.y >= b.y - pad && q.y <= b.y + b.h + pad;
|
|
525
|
+
const union = (list) => list.length ? boxOf(list.flatMap((b) => [{ x: b.x, y: b.y }, { x: b.x + b.w, y: b.y + b.h }])) : null;
|
|
526
|
+
const centre = (b) => ({ x: b.x + b.w / 2, y: b.y + b.h / 2 });
|
|
527
|
+
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
|
+
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
|
+
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 };
|
|
534
|
+
}
|
|
535
|
+
const scopes = (el) => [{ root: el, fr: null }, ...[...(el.localName === "iframe" ? [el] : el.querySelectorAll("iframe"))].flatMap((fr) => {
|
|
536
|
+
try { return fr.contentDocument?.body ? [{ root: fr.contentDocument.body, fr }] : []; } catch { return []; } // other origins: not ours
|
|
537
|
+
})];
|
|
538
|
+
const offBoard = (el) => !!el.closest(".board:not(.on)");
|
|
539
|
+
|
|
540
|
+
// What a mark can be on: cards, blocks, and short texts or named things (also in app frames), in main px
|
|
541
|
+
function things() {
|
|
542
|
+
const out = [];
|
|
543
|
+
// how: the thing as find() takes it, so a mark on it can find it again (and move with it)
|
|
544
|
+
for (const c of area.querySelectorAll("[data-card]")) if (!offBoard(c)) out.push({ name: `card ${c.dataset.card}`, box: rectOf(c), el: c, how: { card: c.dataset.card } });
|
|
545
|
+
for (const b of area.querySelectorAll("[data-block]")) if (!offBoard(b)) {
|
|
546
|
+
const card = b.closest("[data-card]")?.dataset.card;
|
|
547
|
+
out.push({ name: `block ${b.dataset.block}` + (card ? ` (card ${card})` : ""), box: rectOf(b), el: b, how: { card, block: b.dataset.block } });
|
|
548
|
+
}
|
|
549
|
+
for (const { root, fr } of scopes(area)) {
|
|
550
|
+
if (fr && offBoard(fr)) continue;
|
|
551
|
+
const frameCard = fr?.closest("[data-card]")?.dataset.card;
|
|
552
|
+
let n = 0;
|
|
553
|
+
for (const el of root.querySelectorAll("*")) {
|
|
554
|
+
if (++n > 4000) break;
|
|
555
|
+
if (el.closest(".doodle-group, .doodle-note, .card-label, .card-resize, .card-back, .note-pin, script, style") || (!fr && offBoard(el))) continue;
|
|
556
|
+
const named = el.dataset?.name || el.id;
|
|
557
|
+
const text = (el.children.length ? "" : el.innerText || el.value || el.alt || "").trim().replace(/\s+/g, " ");
|
|
558
|
+
if (!named && !(text && text.length <= 40) && !["img", "canvas", "input", "button"].includes(el.localName)) continue;
|
|
559
|
+
const card = frameCard ?? el.closest("[data-card]")?.dataset.card;
|
|
560
|
+
const box = rectOf(el, fr);
|
|
561
|
+
if (box.w < 2 || box.h < 2) continue;
|
|
562
|
+
const what = text ? `"${short(text, 40)}"` : el.dataset?.name ? `[data-name="${el.dataset.name}"]` : el.id ? `#${el.id}` : el.localName;
|
|
563
|
+
const tr = el.closest("tr"), row = tr && tr !== el && short(tr.innerText, 60); // a cell's text alone ("$0", "Food") doesn't say which row
|
|
564
|
+
// the row's longest cell names it best ("Drinks, Golden Gai"): it stays when a quantity or price in it changes
|
|
565
|
+
const key = tr && [...tr.children].map((c) => c.innerText.trim()).filter(Boolean).sort((x, y) => y.length - x.length)[0];
|
|
566
|
+
const label = el.getAttribute?.("aria-label"); // a field's label ("Year 8") names it better than its words ("1994")
|
|
567
|
+
const how = named ? { card, element: named } : label ? { card, element: `[aria-label="${CSS.escape(label)}"]` }
|
|
568
|
+
: text ? { card, text, ...(key && { row: key }) } : { card };
|
|
569
|
+
out.push({ name: what + (row && row !== text ? ` (row: ${row})` : card ? ` (card ${card})` : ""), box, el, leaf: true, how }); // "in <block> (card)" says the card
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return out;
|
|
573
|
+
}
|
|
574
|
+
// A mark in words the page can stand behind: geometry only (the picture shows the rest). Returns {said, anchor}:
|
|
575
|
+
// anchor is the thing the mark is most about (what it rings, underlines, starts from, is written on) and where it
|
|
576
|
+
// is now, so the mark can move with it when the page reflows (a row moved up, a card dragged).
|
|
577
|
+
function say(m) {
|
|
578
|
+
const t = describe(m), how = t.on?.how && { ...t.on.how };
|
|
579
|
+
if (how?.text) { // the same words in several places ("1994" twice): remember which one
|
|
580
|
+
const all = find(how, true), b = t.on.box;
|
|
581
|
+
const i = Array.isArray(all) ? all.findIndex((x) => inBox(b, centre(x.box), 2)) : -1;
|
|
582
|
+
if (all.length > 1 && i >= 0) how.nth = i;
|
|
583
|
+
}
|
|
584
|
+
const at = how && (find(how).box ?? t.on.box); // where it is now, measured as it will be found again
|
|
585
|
+
return { said: t.said, ...(how?.card && { anchor: { ...how, at: { x: Math.round(at.x), y: Math.round(at.y) } } }) };
|
|
586
|
+
}
|
|
587
|
+
function describe(m) {
|
|
588
|
+
const T = things(), leaves = T.filter((t) => t.leaf), b = boxOf(m.points), size = (t) => t.box.w * t.box.h;
|
|
589
|
+
const names = (list) => [...new Set(list.sort((x, y) => size(x) - size(y)).map((t) => t.name))].slice(0, 5).join(", ");
|
|
590
|
+
const at = `, box x=${Math.round(b.x)} y=${Math.round(b.y)} w=${Math.round(b.w)} h=${Math.round(b.h)}`;
|
|
591
|
+
const near = (q, pad = 24) => { // the closest short thing, else the smallest block or card it is in
|
|
592
|
+
const t = leaves.map((t) => [distBox(t.box, q), t]).sort((x, y) => x[0] - y[0])[0];
|
|
593
|
+
if (t && t[0] < pad) return t[1];
|
|
594
|
+
return T.filter((t) => !t.leaf && inBox(t.box, q)).sort((x, y) => size(x) - size(y))[0] ?? null;
|
|
595
|
+
};
|
|
596
|
+
if (m.text) { // what it is written on, or next to (from where the words start)
|
|
597
|
+
const start = { x: b.x, y: centre(b).y }, on = near(start, 50);
|
|
598
|
+
return { said: `handwriting "${m.text}" ${on ? (inBox(on.box, start) ? "on" : "beside") + " " + on.name : "on empty space"}${at}`, on };
|
|
599
|
+
}
|
|
600
|
+
const p = m.points, a = p[0], z = p.at(-1);
|
|
601
|
+
const inside = T.filter((t) => !t.leaf && inBox(t.box, { x: b.x, y: b.y }) && inBox(t.box, { x: b.x + b.w, y: b.y + b.h })).sort((x, y) => size(x) - size(y))[0];
|
|
602
|
+
const where = inside ? ` in ${inside.name}` : "";
|
|
603
|
+
if (Math.hypot(a.x - z.x, a.y - z.y) < Math.max(34, Math.max(b.w, b.h) * .15) && b.w > 24 && b.h > 12) { // closed (ends near, for its size): what it goes round
|
|
604
|
+
let enc = leaves.filter((t) => inPoly(p, centre(t.box)));
|
|
605
|
+
if (!enc.length) enc = T.filter((t) => !t.leaf && inPoly(p, centre(t.box)));
|
|
606
|
+
return { said: `closed loop around ${enc.length ? names(enc) : "empty space"}${where}${at}`, on: enc.sort((x, y) => size(x) - size(y))[0] ?? inside };
|
|
607
|
+
}
|
|
608
|
+
if (b.h < 18 && b.w > 30) { // flat: under or through short things
|
|
609
|
+
const over = leaves.filter((t) => b.x < t.box.x + t.box.w && b.x + b.w > t.box.x);
|
|
610
|
+
const under = over.filter((t) => b.y >= t.box.y + t.box.h - 5 && b.y <= t.box.y + t.box.h + 14);
|
|
611
|
+
const through = over.filter((t) => centre(b).y > t.box.y + t.box.h * .25 && centre(b).y < t.box.y + t.box.h * .75);
|
|
612
|
+
if (under.length) return { said: `line under ${names(under)}${where}${at}`, on: under[0] };
|
|
613
|
+
if (through.length) return { said: `line through ${names(through)}${where}${at}`, on: through[0] };
|
|
614
|
+
}
|
|
615
|
+
const from = near(a), to = near(z);
|
|
616
|
+
const over = leaves.filter((t) => t !== from && t !== to && p.filter((q) => inBox(t.box, q)).length >= 3);
|
|
617
|
+
return { said: `open line from ${from ? from.name : "empty space"} to ${to ? to.name : "empty space"}`
|
|
618
|
+
+ (over.length ? `, passing over ${names(over)}` : "") + where + at, on: from ?? to ?? inside };
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// A thing an agent names ({card, block, element, text, mark, note}) -> {box (main px), board} or {error}.
|
|
622
|
+
// An anchor of the user's mark may add row (the table row's longest cell) or nth (which of the same words).
|
|
623
|
+
// every: with text, all the places that have it, in page order (for nth)
|
|
624
|
+
function find(t, every = false) {
|
|
625
|
+
if (t.at) return spot(t.at);
|
|
626
|
+
const r = findMain(t, every);
|
|
627
|
+
const missed = every ? !r.length : !!r.error;
|
|
628
|
+
if (missed && !t.card && !t.block && (t.element || t.text)) { // not among the cards: maybe the page's own controls
|
|
629
|
+
const u = findUi(t, every);
|
|
630
|
+
if (every ? u.length : !u.error) return u;
|
|
631
|
+
}
|
|
632
|
+
return r;
|
|
633
|
+
}
|
|
634
|
+
// The page around the main space (the palette, the rail, the message line, the board shelf): fixed to the window,
|
|
635
|
+
// so a mark on one of them is drawn in window px (fixed: true) and stays put when the main space scrolls
|
|
636
|
+
const uiRoots = () => [...document.body.children].filter((c) => c !== area && c.id !== "panel"
|
|
637
|
+
&& !c.matches("script, style, .doodle-group, .doodle-peek, .doodle-big, .note-pin") && c.getClientRects().length
|
|
638
|
+
&& !(c.offsetWidth > innerWidth * .8 && c.offsetHeight > innerHeight * .8)); // a see-through layer over everything (viewers) isn't a control
|
|
639
|
+
const winBox = (el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }; };
|
|
640
|
+
function findUi(t, every) {
|
|
641
|
+
const all = [], seen = new Set(), add = (el) => {
|
|
642
|
+
if (seen.has(el)) return;
|
|
643
|
+
seen.add(el);
|
|
644
|
+
const box = winBox(el);
|
|
645
|
+
if (box.w > 0 && box.h > 0) all.push({ box, len: (el.textContent || el.title || "").length, off: false, fixed: true });
|
|
646
|
+
};
|
|
647
|
+
// an element: a name first ("text" is the Text tool, not the message line's #text), then an id, then a selector
|
|
648
|
+
if (t.element) for (const sel of [`[data-name="${CSS.escape(t.element)}"]`, `#${CSS.escape(t.element)}`, t.element]) {
|
|
649
|
+
for (const root of uiRoots()) try { if (root.matches(sel)) add(root); root.querySelectorAll(sel).forEach(add); } catch {} // not a valid selector
|
|
650
|
+
if (all.length) break;
|
|
651
|
+
}
|
|
652
|
+
for (const root of uiRoots()) {
|
|
653
|
+
if (t.text) { // words on a control, or its tooltip (an icon button says what it is in its title)
|
|
654
|
+
const want = t.text.trim().toLowerCase();
|
|
655
|
+
for (const el of [root, ...root.querySelectorAll("*")])
|
|
656
|
+
if ((!el.children.length && (el.textContent || "").trim().toLowerCase().includes(want))
|
|
657
|
+
|| (el.title || el.getAttribute("aria-label") || "").toLowerCase().includes(want)) add(el);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (every) return all;
|
|
661
|
+
const best = all.reduce((b, m) => (!b || m.len < b.len ? m : b), null);
|
|
662
|
+
return best ? { box: best.box, board: Number(board()), fixed: true } : { error: `no ${t.element ? `element '${t.element}'` : `text "${t.text}"`} on the page` };
|
|
663
|
+
}
|
|
664
|
+
// A spot the agent read off the user's screen (screen_see): {x, y, width, height} in window px. Over the page's own
|
|
665
|
+
// controls it stays fixed to the window; over the main space it is turned into main px, so it scrolls with the cards.
|
|
666
|
+
function spot(a) {
|
|
667
|
+
const x = Number(a.x), y = Number(a.y), w = Number(a.width ?? 0), h = Number(a.height ?? 0);
|
|
668
|
+
if (![x, y, w, h].every(Number.isFinite)) return { error: "at needs numbers: {x, y, width, height} in window px" };
|
|
669
|
+
if (x < 0 || y < 0 || x > innerWidth || y > innerHeight) return { error: `at (${x}, ${y}) is outside the window (${innerWidth}×${innerHeight})` };
|
|
670
|
+
const under = document.elementsFromPoint(x + w / 2, y + h / 2).find((e) => !e.closest(".doodle-group, svg.doodle-ink"));
|
|
671
|
+
if (under && !area.contains(under) && under !== document.documentElement && under !== document.body)
|
|
672
|
+
return { box: { x, y, w, h }, board: Number(board()), fixed: true };
|
|
673
|
+
const p = inMain(x, y);
|
|
674
|
+
return { box: { x: p.x, y: p.y, w, h }, board: Number(board()) };
|
|
675
|
+
}
|
|
676
|
+
function findMain(t, every) {
|
|
677
|
+
if (t.mark) {
|
|
678
|
+
const m = saved.marks[t.mark];
|
|
679
|
+
if (!m) return { error: `no mark '${t.mark}'` };
|
|
680
|
+
const b = drawn[t.mark] || (m.points && boxOf(m.points));
|
|
681
|
+
return b ? { box: b, board: m.board } : { error: `mark ${t.mark} is not drawn on the page` };
|
|
682
|
+
}
|
|
683
|
+
if (t.note) {
|
|
684
|
+
const pin = document.querySelector(`.note-pin[data-note="${CSS.escape(t.note)}"]`);
|
|
685
|
+
return pin ? { box: rectOf(pin), board: Number(pin.dataset.board) || 1 } : { error: `no note '${t.note}' on the page` };
|
|
686
|
+
}
|
|
687
|
+
let el = area, what = "the page";
|
|
688
|
+
if (t.card) {
|
|
689
|
+
el = area.querySelector(`[data-card="${CSS.escape(t.card)}"]`);
|
|
690
|
+
if (!el) return { error: `no card '${t.card}' on the page` };
|
|
691
|
+
what = `card ${t.card}`;
|
|
692
|
+
}
|
|
693
|
+
if (t.block) {
|
|
694
|
+
const b = el.querySelector(`[data-block="${CSS.escape(t.block)}"]`);
|
|
695
|
+
if (!b) return { error: `no block '${t.block}' in ${what}` };
|
|
696
|
+
el = b;
|
|
697
|
+
what = `block ${t.block}`;
|
|
698
|
+
}
|
|
699
|
+
let found = { el, fr: null };
|
|
700
|
+
const tries = (sel) => [`[data-name="${CSS.escape(sel)}"]`, `#${CSS.escape(sel)}`, sel]; // a name first, then an id, then a selector
|
|
701
|
+
if (t.element) {
|
|
702
|
+
found = null;
|
|
703
|
+
for (const sc of scopes(el)) for (const sel of tries(t.element)) {
|
|
704
|
+
if (found) break;
|
|
705
|
+
try { const hit = sc.root.querySelector(sel); if (hit) found = { el: hit, fr: sc.fr }; } catch {} // not a valid selector
|
|
706
|
+
}
|
|
707
|
+
if (!found) return { error: `no element '${t.element}' in ${what}` };
|
|
708
|
+
what = `element ${t.element}`;
|
|
709
|
+
}
|
|
710
|
+
if (t.row) { // the table row whose text has this (an anchor of the user's mark: "Drinks, Golden Gai")
|
|
711
|
+
let hit = null;
|
|
712
|
+
for (const sc of scopes(found.el)) for (const tr of sc.root.querySelectorAll("tr"))
|
|
713
|
+
if (tr.textContent.includes(t.row) && (!t.text || tr.textContent.includes(t.text)) && (!hit || (offBoard(hit.fr || hit.el) && !offBoard(sc.fr || tr)))) hit = { el: tr, fr: sc.fr };
|
|
714
|
+
if (!hit) return { error: `no row with "${t.row}" in ${what}` };
|
|
715
|
+
found = hit;
|
|
716
|
+
what = `the row "${t.row}"`;
|
|
717
|
+
}
|
|
718
|
+
const brd = Number((found.fr || found.el).closest?.("[data-board]")?.dataset.board) || Number(board());
|
|
719
|
+
if (t.text) { // the words themselves: every place that has them, just those characters
|
|
720
|
+
const want = t.text.trim().toLowerCase().replace(/\s+/g, " "), all = [];
|
|
721
|
+
for (const sc of found.el === area ? scopes(area) : found.el.localName === "iframe" ? scopes(found.el).slice(1) : [{ root: found.el, fr: found.fr }, ...scopes(found.el).slice(1)]) {
|
|
722
|
+
const walk = sc.root.ownerDocument.createTreeWalker(sc.root, NodeFilter.SHOW_TEXT);
|
|
723
|
+
for (let n; (n = walk.nextNode());) {
|
|
724
|
+
if (n.parentElement?.closest(".doodle-group, .doodle-note, .card-label, .card-back, .note-pin, script, style")) continue;
|
|
725
|
+
const i = n.data.toLowerCase().replace(/\s/g, " ").indexOf(want);
|
|
726
|
+
if (i < 0) continue;
|
|
727
|
+
const range = sc.root.ownerDocument.createRange();
|
|
728
|
+
range.setStart(n, i);
|
|
729
|
+
range.setEnd(n, i + want.length);
|
|
730
|
+
const box = rectOf(range, sc.fr);
|
|
731
|
+
if (box.w > 0 && box.h > 0) all.push({ box, len: n.data.length, off: offBoard(sc.fr || n.parentElement) });
|
|
732
|
+
}
|
|
733
|
+
// words in a field (an app's editable title, a form): not text in the page, but what the user sees
|
|
734
|
+
for (const f of sc.root.querySelectorAll("input, textarea, select")) {
|
|
735
|
+
const v = (f.localName === "select" ? f.selectedOptions[0]?.text ?? "" : f.value ?? "").toLowerCase().replace(/\s+/g, " ");
|
|
736
|
+
const box = v.includes(want) && rectOf(f, sc.fr);
|
|
737
|
+
if (box && box.w > 0 && box.h > 0) all.push({ box, len: v.length, off: offBoard(sc.fr || f) });
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (every) return all;
|
|
741
|
+
// which one: the nth (an anchor that knows the same words show more than once), else the board shown, the shortest text
|
|
742
|
+
const best = t.nth != null && all[t.nth] ? all[t.nth]
|
|
743
|
+
: all.reduce((b, m) => (!b || m.off < b.off || (m.off === b.off && m.len < b.len) ? m : b), null);
|
|
744
|
+
return best ? { box: best.box, board: brd } : { error: `no text "${t.text}" in ${what}` };
|
|
745
|
+
}
|
|
746
|
+
if (found.el === area) return { error: "name a card, block, element, text or mark" };
|
|
747
|
+
const box = found.el.localName === "body" ? rectOf(found.fr) : rectOf(found.el, found.fr);
|
|
748
|
+
return box.w || box.h ? { box, board: brd } : { error: `${what} is not visible` };
|
|
749
|
+
}
|
|
750
|
+
page.on("doodle_check", ({ req, target }) => page.send({ type: "doodle_checked", req, ...find(target) }));
|
|
751
|
+
page.provide("marks", { find, inMain, colour });
|
|
752
|
+
|
|
753
|
+
// An agent's gesture on a box -> path data (main px), drawn by hand: a little wobble, the ring doesn't quite close
|
|
754
|
+
const wob = (i, k = 0) => Math.sin(i * 2.3 + k) * 1.2 + Math.cos(i * 1.7 + k) * .8;
|
|
755
|
+
const pt = (x, y) => ({ x, y });
|
|
756
|
+
const edge = (b, toward) => { const c = centre(b), dx = toward.x - c.x, dy = toward.y - c.y, l = Math.hypot(dx, dy) || 1;
|
|
757
|
+
const k = Math.min(Math.abs(b.w / 2 / (dx || 1e-9)), Math.abs(b.h / 2 / (dy || 1e-9)));
|
|
758
|
+
return pt(c.x + dx * k + dx / l * 6, c.y + dy * k + dy / l * 6); };
|
|
759
|
+
function gesture(g, b, t) {
|
|
760
|
+
const c = centre(b), n = (k, fn) => smooth(Array.from({ length: k }, (_, i) => fn(i)));
|
|
761
|
+
if (g === "ring") { const rx = b.w / 2 + Math.min(10, 3 + b.w * .1), ry = b.h / 2 + Math.min(7, 2 + b.h * .1); // snug round small things
|
|
762
|
+
return [n(40, (i) => { const a = (-100 + i * 9.4) * Math.PI / 180; return pt(c.x + rx * Math.cos(a) * (1 + i / 420) + wob(i), c.y + ry * Math.sin(a) + wob(i, 3)); })]; }
|
|
763
|
+
if (g === "underline") return [n(12, (i) => pt(b.x - 2 + (b.w + 4) * i / 11, b.y + b.h + 4 + wob(i) * .8))];
|
|
764
|
+
if (g === "strike") return [n(10, (i) => pt(b.x - 4 + (b.w + 8) * i / 9, c.y + wob(i) * .7))];
|
|
765
|
+
if (g === "tick") { const x = b.x + b.w + 8, y = c.y; return [`M${x} ${y} L${x + 5} ${y + 7} L${x + 16} ${y - 10}`]; }
|
|
766
|
+
if (g === "cross") return [`M${b.x - 3} ${b.y - 3} L${b.x + b.w + 3} ${b.y + b.h + 3}`, `M${b.x + b.w + 3} ${b.y - 3} L${b.x - 3} ${b.y + b.h + 3}`];
|
|
767
|
+
if (g === "arrow" && t) { const s = edge(b, centre(t)), e = edge(t, centre(b)), m = pt((s.x + e.x) / 2, (s.y + e.y) / 2 - 18), a = Math.atan2(e.y - m.y, e.x - m.x);
|
|
768
|
+
return [`M${f(s.x)} ${f(s.y)} Q${f(m.x)} ${f(m.y)} ${f(e.x)} ${f(e.y)}`,
|
|
769
|
+
`M${f(e.x - 12 * Math.cos(a - .5))} ${f(e.y - 12 * Math.sin(a - .5))} L${f(e.x)} ${f(e.y)} L${f(e.x - 12 * Math.cos(a + .5))} ${f(e.y - 12 * Math.sin(a + .5))}`]; }
|
|
770
|
+
return [];
|
|
771
|
+
}
|
|
772
|
+
// Where an agent's words go: the first spot near the thing that covers nothing (no text on the page, no other
|
|
773
|
+
// words): right of it, past its card's edge on the same line, below, above; then down the card's margin past
|
|
774
|
+
// other words; else right of it anyway
|
|
775
|
+
function placeWords(text, b, el, on) {
|
|
776
|
+
const w = el.offsetWidth || text.length * 9 + 10, h = el.offsetHeight || 24, room = area.scrollWidth - 8;
|
|
777
|
+
const cardEl = on?.card && area.querySelector(`[data-card="${CSS.escape(on.card)}"]`), card = cardEl && rectOf(cardEl);
|
|
778
|
+
const busy = [...things().filter((t) => t.leaf && !(inBox(b, centre(t.box), 2))).map((t) => t.box),
|
|
779
|
+
...Object.values(words).filter((x) => x.el !== el && x.el.isConnected && !x.fixed).map((x) => x.box)];
|
|
780
|
+
const free = (p) => p.x >= 4 && p.x + w <= room && p.y >= 0 && !busy.some((t) => !(p.x + w <= t.x || p.x >= t.x + t.w || p.y + h <= t.y || p.y >= t.y + t.h));
|
|
781
|
+
const mid = b.y + b.h / 2 - h / 2;
|
|
782
|
+
const spots = [{ x: b.x + b.w + 14, y: mid }, ...(card ? [{ x: card.x + card.w + 14, y: mid }] : []),
|
|
783
|
+
{ x: b.x, y: b.y + b.h + 6 }, { x: b.x, y: b.y - h - 4 },
|
|
784
|
+
...(card ? Array.from({ length: 8 }, (_, i) => ({ x: card.x + card.w + 14, y: mid + (i % 2 ? -1 : 1) * Math.ceil((i + 1) / 2) * h })) : [])];
|
|
785
|
+
for (const p of spots) if (free(p)) return { ...p, w, h };
|
|
786
|
+
const p = spots[0];
|
|
787
|
+
return { x: Math.min(p.x, room - w), y: p.y, w, h };
|
|
788
|
+
}
|
|
789
|
+
const words = {}; // agent mark id -> {el, box, fixed}: its handwriting, so others step past it
|
|
790
|
+
// Words by a mark on the page's own controls (window px): below it, else right, left, above; inside the window
|
|
791
|
+
function placeFixed(text, b, el) {
|
|
792
|
+
const w = el.offsetWidth || text.length * 9 + 10, h = el.offsetHeight || 24;
|
|
793
|
+
const busy = [...Object.values(words).filter((x) => x.el !== el && x.el.isConnected && x.fixed).map((x) => x.box),
|
|
794
|
+
...uiRoots().map(winBox)]; // and not on the palette, the rail, the message line
|
|
795
|
+
const ok = (p) => p.x >= 4 && p.y >= 4 && p.x + w <= innerWidth - 4 && p.y + h <= innerHeight - 4
|
|
796
|
+
&& !busy.some((t) => !(p.x + w <= t.x || p.x >= t.x + t.w || p.y + h <= t.y || p.y >= t.y + t.h));
|
|
797
|
+
const spots = [{ x: b.x + b.w / 2 - w / 2, y: b.y + b.h + 10 }, { x: b.x + b.w + 14, y: b.y + b.h / 2 - h / 2 },
|
|
798
|
+
{ x: b.x - w - 14, y: b.y + b.h / 2 - h / 2 }, { x: b.x + b.w / 2 - w / 2, y: b.y - h - 8 }];
|
|
799
|
+
for (let i = 0; i < 4; i++) for (const p of spots) { const q = { x: p.x, y: p.y + i * h }; if (ok(q)) return { ...q, w, h }; }
|
|
800
|
+
return { x: Math.max(4, Math.min(spots[0].x, innerWidth - w - 4)), y: spots[0].y, w, h };
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function fixedOf(el) { // the window-fixed twin of a group's container (marks on the palette, the rail, ...)
|
|
804
|
+
const key = (el.classList.contains("ghost") ? `${el.dataset.group}-ghost` : el.dataset.group) + "~fixed";
|
|
805
|
+
let f = containers[key];
|
|
806
|
+
if (!f) {
|
|
807
|
+
f = containers[key] = Object.assign(document.createElement("div"), { className: "doodle-group doodle-fixed" });
|
|
808
|
+
f.dataset.group = el.dataset.group;
|
|
809
|
+
f.append(document.createElementNS(SVG, "svg"));
|
|
810
|
+
document.body.append(f); // on the body, with data-board: board/ shows it only on its board
|
|
811
|
+
}
|
|
812
|
+
f.dataset.board = el.dataset.board;
|
|
813
|
+
f.className = el.className + " doodle-fixed";
|
|
814
|
+
return f;
|
|
815
|
+
}
|
|
816
|
+
function container(id, g, ghost = false) {
|
|
817
|
+
const key = ghost ? `${id}-ghost` : id;
|
|
818
|
+
let el = containers[key];
|
|
819
|
+
if (!el) {
|
|
820
|
+
el = containers[key] = Object.assign(document.createElement("div"), { className: "doodle-group" });
|
|
821
|
+
el.dataset.group = id;
|
|
822
|
+
el.append(document.createElementNS(SVG, "svg"));
|
|
823
|
+
area.append(el);
|
|
824
|
+
}
|
|
825
|
+
el.dataset.board = g.board ?? 1;
|
|
826
|
+
el.classList.toggle("dim", !!g.dim && !ghost);
|
|
827
|
+
el.classList.toggle("ghost", ghost);
|
|
828
|
+
return el;
|
|
829
|
+
}
|
|
830
|
+
function pathIn(el, d, ink, animate) {
|
|
831
|
+
const p = document.createElementNS(SVG, "path");
|
|
832
|
+
p.setAttribute("d", d);
|
|
833
|
+
p.style.setProperty("--ink", ink);
|
|
834
|
+
el.firstChild.append(p);
|
|
835
|
+
if (animate && !matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
836
|
+
const L = p.getTotalLength();
|
|
837
|
+
p.style.strokeDasharray = L;
|
|
838
|
+
p.style.strokeDashoffset = L;
|
|
839
|
+
p.getBoundingClientRect();
|
|
840
|
+
p.style.transition = "stroke-dashoffset .7s ease-out";
|
|
841
|
+
requestAnimationFrame(() => { p.style.strokeDashoffset = 0; });
|
|
842
|
+
}
|
|
843
|
+
return p;
|
|
844
|
+
}
|
|
845
|
+
function moved(m) { // how far a user's mark's anchor has moved since it was drawn (0 if it can't be found)
|
|
846
|
+
const f = m.anchor && find(m.anchor);
|
|
847
|
+
if (!f?.box) return { x: 0, y: 0, key: m.anchor ? "not found" : "fixed" };
|
|
848
|
+
const x = Math.round(f.box.x - m.anchor.at.x), y = Math.round(f.box.y - m.anchor.at.y);
|
|
849
|
+
return { x, y, key: `${x},${y}` };
|
|
850
|
+
}
|
|
851
|
+
// Draw one mark into its group's container (again, if it is there: its old parts go first)
|
|
852
|
+
function drawMark(id, m, el, animate = false) {
|
|
853
|
+
for (const c of [el, containers[(el.classList.contains("ghost") ? `${el.dataset.group}-ghost` : el.dataset.group) + "~fixed"]])
|
|
854
|
+
for (const old of c?.querySelectorAll(`[data-mark="${id}"]`) ?? []) old.remove();
|
|
855
|
+
const ink = INK_OF(m.by), tag = (n) => { n.dataset.mark = id; return n; };
|
|
856
|
+
if (m.kind === "line" || m.kind === "words") { // the user's: where drawn, moved as far as its anchor moved since
|
|
857
|
+
const d = moved(m), pts = m.points.map((q) => ({ x: q.x + d.x, y: q.y + d.y }));
|
|
858
|
+
if (m.kind === "line") tag(pathIn(el, smooth(pts), ink, false));
|
|
859
|
+
else {
|
|
860
|
+
const w = tag(Object.assign(document.createElement("div"), { className: "words", textContent: m.text }));
|
|
861
|
+
Object.assign(w.style, { left: `${pts[0].x}px`, top: `${pts[0].y - 14}px` });
|
|
862
|
+
w.style.setProperty("--ink", ink);
|
|
863
|
+
el.append(w);
|
|
864
|
+
}
|
|
865
|
+
drawn[id] = Object.assign(boxOf(pts), { pts, at: d.key });
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const at = find(m.on), b = at.box || m.box; // an agent's gesture: on the thing as it is now
|
|
869
|
+
if (!b) return;
|
|
870
|
+
const fixed = at.box ? !!at.fixed : !!m.fixed; // on the page's own controls: drawn in window px, in the fixed twin
|
|
871
|
+
if (fixed) el = fixedOf(el);
|
|
872
|
+
const tt = m.to ? find(m.to) : null;
|
|
873
|
+
let t = tt?.box || m.toBox || null;
|
|
874
|
+
if (t && !!(tt?.box ? tt.fixed : m.toFixed) !== fixed) { // the arrow's two ends in one system of px
|
|
875
|
+
const q = fixed ? inWindow({ x: t.x, y: t.y }) : inMain(t.x, t.y);
|
|
876
|
+
t = { ...t, x: q.x, y: q.y };
|
|
877
|
+
}
|
|
878
|
+
const parts = gesture(m.gesture, b, t).map((d) => tag(pathIn(el, d, ink, animate)));
|
|
879
|
+
const boxes = parts.map((p) => { const r = p.getBBox(); return { x: r.x, y: r.y, w: r.width, h: r.height }; });
|
|
880
|
+
if (m.words) {
|
|
881
|
+
const w = tag(Object.assign(document.createElement("div"), { className: "words agent", textContent: m.words }));
|
|
882
|
+
w.style.setProperty("--ink", ink);
|
|
883
|
+
el.append(w);
|
|
884
|
+
const near = m.gesture === "arrow" && t ? { x: (centre(b).x + centre(t).x) / 2 - 10, y: (centre(b).y + centre(t).y) / 2 - 34, w: 20, h: 20 } : b;
|
|
885
|
+
const pos = fixed ? placeFixed(m.words, near, w) : placeWords(m.words, near, w, m.on);
|
|
886
|
+
Object.assign(w.style, { left: `${pos.x}px`, top: `${pos.y}px` });
|
|
887
|
+
words[id] = { el: w, box: pos, fixed };
|
|
888
|
+
boxes.push(pos);
|
|
889
|
+
if (animate) { w.style.opacity = 0; w.style.transition = "opacity .5s .5s"; requestAnimationFrame(() => { w.style.opacity = 1; }); }
|
|
890
|
+
}
|
|
891
|
+
drawn[id] = union(boxes) || b;
|
|
892
|
+
drawn[id].fixed = fixed;
|
|
893
|
+
drawn[id].at = !at.box ? "saved box" : // not found yet (the cards draw after this): the next look draws it again
|
|
894
|
+
`${Math.round(b.x)},${Math.round(b.y)},${Math.round(b.w)},${Math.round(b.h)}` + (t ? `>${Math.round(t.x)},${Math.round(t.y)}` : "");
|
|
895
|
+
}
|
|
896
|
+
// The ✓ chip: by the user's marks in the group (else by the agent's); clears the group for everyone
|
|
897
|
+
function chip(id, el) {
|
|
898
|
+
el.querySelector(":scope > .chip")?.remove();
|
|
899
|
+
const mine = Object.entries(saved.marks).filter(([, m]) => m.group === id);
|
|
900
|
+
const yours = mine.filter(([, m]) => m.by === "user");
|
|
901
|
+
containers[el.dataset.group + "~fixed"]?.querySelector(":scope > .chip")?.remove();
|
|
902
|
+
const inMainSpace = (yours.length ? yours : mine).map(([mid]) => drawn[mid]).filter((d) => d && !d.fixed);
|
|
903
|
+
if (!inMainSpace.length && mine.some(([mid]) => drawn[mid]?.fixed)) el = fixedOf(el);
|
|
904
|
+
const b = union(inMainSpace.length ? inMainSpace : mine.map(([mid]) => drawn[mid]).filter(Boolean));
|
|
905
|
+
if (!b) return;
|
|
906
|
+
const c = Object.assign(document.createElement("button"), { className: "chip", textContent: "✓", type: "button",
|
|
907
|
+
title: "Done: clear these marks (yours and the agent's). You can bring them back from the chat log." });
|
|
908
|
+
// by the user's marks: the box's corner (a ring leaves it empty); only the agent's: just outside, clear of its ticks
|
|
909
|
+
Object.assign(c.style, yours.length ? { left: `${b.x + b.w - 18}px`, top: `${Math.max(0, b.y - 4)}px` }
|
|
910
|
+
: { left: `${b.x + b.w + 8}px`, top: `${Math.max(0, b.y - 22)}px` });
|
|
911
|
+
c.onclick = (e) => { e.stopPropagation(); page.send({ type: "doodle_clear", group: id }); };
|
|
912
|
+
el.append(c);
|
|
913
|
+
}
|
|
914
|
+
function drawGroup(id, ghost = false, animate = null) {
|
|
915
|
+
const g = saved.groups[id];
|
|
916
|
+
if (!g) return;
|
|
917
|
+
const el = container(id, g, ghost);
|
|
918
|
+
for (const [mid, m] of Object.entries(saved.marks)) if (m.group === id) drawMark(mid, m, el, animate === mid);
|
|
919
|
+
if (!ghost) chip(id, el);
|
|
920
|
+
}
|
|
921
|
+
function dropGroup(key, fade = false) {
|
|
922
|
+
if (!key.endsWith("~fixed")) dropGroup(key + "~fixed", fade); // and its window-fixed twin
|
|
923
|
+
const el = containers[key];
|
|
924
|
+
if (!el) return;
|
|
925
|
+
delete containers[key];
|
|
926
|
+
if (!fade) return el.remove();
|
|
927
|
+
el.classList.add("gone");
|
|
928
|
+
setTimeout(() => el.remove(), 800);
|
|
929
|
+
}
|
|
930
|
+
const labelOf = (id) => { // "your ring around "12.50"" style words for the chat log
|
|
931
|
+
const g = saved.groups[id];
|
|
932
|
+
return g ? (g.by === "user" ? `your marks (${g.label})` : `${g.by}'s marks`) : "a group of marks";
|
|
933
|
+
};
|
|
934
|
+
// Cleared groups come back from the chat: hover shows a ghost of them, a click brings them back
|
|
935
|
+
function backFrom(el, id) {
|
|
936
|
+
if (el.dataset.back) return;
|
|
937
|
+
el.dataset.back = id;
|
|
938
|
+
el.addEventListener("mouseenter", () => {
|
|
939
|
+
const cleared = saved.groups[id]?.state === "cleared";
|
|
940
|
+
el.classList.toggle("doodle-back", cleared);
|
|
941
|
+
el.title = cleared ? "These marks were cleared: click to bring them back" : "";
|
|
942
|
+
if (cleared) drawGroup(id, true);
|
|
943
|
+
});
|
|
944
|
+
el.addEventListener("mouseleave", () => dropGroup(`${id}-ghost`));
|
|
945
|
+
el.addEventListener("click", (e) => {
|
|
946
|
+
if (saved.groups[id]?.state !== "cleared") return;
|
|
947
|
+
e.stopPropagation();
|
|
948
|
+
dropGroup(`${id}-ghost`);
|
|
949
|
+
page.send({ type: "doodle_restore", group: id });
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const log = document.getElementById("log");
|
|
953
|
+
const scanLog = (root) => { for (const el of root.querySelectorAll?.("[data-raw]") ?? []) {
|
|
954
|
+
const m = /\ngroup: (g\d+)/.exec(el.dataset.raw);
|
|
955
|
+
if (m) backFrom(el, m[1]);
|
|
956
|
+
} };
|
|
957
|
+
if (log) new MutationObserver((list) => { for (const r of list) for (const n of r.addedNodes) if (n.nodeType === 1) scanLog(n.parentElement || n); })
|
|
958
|
+
.observe(log, { childList: true, subtree: true });
|
|
959
|
+
|
|
960
|
+
page.on("doodle_group", ({ group, marks: added, ...g }) => {
|
|
961
|
+
if (added) drop(); // a new group of the user's: drawn below, so the held copy goes
|
|
962
|
+
const before = saved.groups[group];
|
|
963
|
+
saved.groups[group] = g;
|
|
964
|
+
Object.assign(saved.marks, added || {});
|
|
965
|
+
if (g.state === "cleared") {
|
|
966
|
+
dropGroup(group, true);
|
|
967
|
+
if (before && before.state !== "cleared") {
|
|
968
|
+
const who = g.clearedBy === "user" ? "You" : g.clearedBy;
|
|
969
|
+
const line = page.note(`${who} cleared ${labelOf(group)}: hover to see them, click to bring them back`);
|
|
970
|
+
if (line) backFrom(line, group);
|
|
971
|
+
}
|
|
972
|
+
} else drawGroup(group);
|
|
973
|
+
});
|
|
974
|
+
page.on("doodle_mark", ({ id, ...m }) => {
|
|
975
|
+
saved.marks[id] = m;
|
|
976
|
+
const g = saved.groups[m.group];
|
|
977
|
+
if (g && g.state !== "cleared") drawGroup(m.group, false, id);
|
|
978
|
+
});
|
|
979
|
+
page.on("doodle_gone", ({ group }) => { // its board was removed
|
|
980
|
+
for (const [id, m] of Object.entries(saved.marks)) if (m.group === group) { delete saved.marks[id]; delete drawn[id]; delete words[id]; }
|
|
981
|
+
delete saved.groups[group];
|
|
982
|
+
dropGroup(group);
|
|
983
|
+
});
|
|
984
|
+
page.on("doodle_erased", ({ id, group }) => {
|
|
985
|
+
delete saved.marks[id];
|
|
986
|
+
delete drawn[id];
|
|
987
|
+
delete words[id];
|
|
988
|
+
erased.delete(id);
|
|
989
|
+
for (const c of [containers[group], containers[group + "~fixed"]]) for (const n of c?.querySelectorAll(`[data-mark="${id}"]`) ?? []) n.remove();
|
|
990
|
+
if (!Object.values(saved.marks).some((m) => m.group === group)) { delete saved.groups[group]; dropGroup(group); }
|
|
991
|
+
else if (containers[group]) chip(group, containers[group]);
|
|
992
|
+
});
|
|
993
|
+
// Agents' marks follow what they are on (a card moved, a row changed): look again now and then
|
|
994
|
+
setInterval(() => {
|
|
995
|
+
for (const [id, m] of Object.entries(saved.marks)) {
|
|
996
|
+
const g = saved.groups[m.group];
|
|
997
|
+
if (!g || g.state === "cleared" || String(m.board) !== board() || !containers[m.group]) continue;
|
|
998
|
+
if (m.kind !== "gesture") { // the user's: follow its anchor
|
|
999
|
+
if (m.anchor && moved(m).key !== drawn[id]?.at) { drawMark(id, m, containers[m.group]); chip(m.group, containers[m.group]); }
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
const b = find(m.on).box, t = m.to ? find(m.to).box : null;
|
|
1003
|
+
if (!b) continue;
|
|
1004
|
+
const at = `${Math.round(b.x)},${Math.round(b.y)},${Math.round(b.w)},${Math.round(b.h)}` + (t ? `>${Math.round(t.x)},${Math.round(t.y)}` : "");
|
|
1005
|
+
if (drawn[id]?.at !== at) { drawMark(id, m, containers[m.group]); chip(m.group, containers[m.group]); }
|
|
1006
|
+
}
|
|
1007
|
+
}, 1500);
|
|
1008
|
+
try {
|
|
1009
|
+
saved = await (await fetch("/doodle/all")).json();
|
|
1010
|
+
for (const [id, g] of Object.entries(saved.groups)) if (g.state !== "cleared") drawGroup(id);
|
|
1011
|
+
scanLog(log);
|
|
1012
|
+
} catch {}
|
|
1013
|
+
}
|