@chalky/chalky 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/README.md +46 -2
- package/app/SYSTEM.md +30 -0
- package/app/extensions/appdata/README.md +10 -0
- package/app/extensions/appdata/guide.md +4 -0
- package/app/extensions/appdata/index.ts +47 -0
- package/app/extensions/doodle/README.md +15 -0
- package/app/extensions/doodle/browser.js +388 -0
- package/app/extensions/doodle/guide.md +6 -0
- package/app/extensions/doodle/index.ts +54 -0
- package/app/extensions/files/browser.js +96 -0
- package/app/extensions/files/guide.md +7 -0
- package/app/extensions/files/index.ts +73 -0
- package/app/extensions/frames/README.md +37 -0
- package/app/extensions/frames/browser.js +163 -0
- package/app/extensions/frames/guide.md +7 -0
- package/app/extensions/frames/index.ts +76 -0
- package/app/extensions/mcp/browser.js +64 -0
- package/app/extensions/mcp/guide.md +5 -0
- package/app/extensions/mcp/index.ts +64 -0
- package/app/extensions/model/index.ts +20 -0
- package/app/extensions/page/index.ts +96 -0
- package/app/extensions/page/page.html +615 -0
- package/app/extensions/point/README.md +22 -0
- package/app/extensions/point/browser.js +245 -0
- package/app/extensions/point/guide.md +6 -0
- package/app/extensions/point/index.ts +97 -0
- package/app/extensions/prompt/index.ts +19 -0
- package/app/extensions/reload/index.ts +21 -0
- package/app/extensions/screen/browser.js +76 -0
- package/app/extensions/screen/index.ts +80 -0
- package/app/extensions/steps/index.ts +17 -0
- package/app/extensions/swarm/README.md +47 -0
- package/app/extensions/swarm/guide.md +11 -0
- package/app/extensions/swarm/index.ts +304 -0
- package/app/extensions/ui/README.md +69 -0
- package/app/extensions/ui/agents.js +45 -0
- package/app/extensions/ui/browser.js +363 -0
- package/app/extensions/ui/components/App.js +28 -0
- package/app/extensions/ui/components/Button.js +28 -0
- package/app/extensions/ui/components/Card.js +23 -0
- package/app/extensions/ui/components/Column.js +17 -0
- package/app/extensions/ui/components/Markdown.js +37 -0
- package/app/extensions/ui/components/Row.js +17 -0
- package/app/extensions/ui/components/Table.js +31 -0
- package/app/extensions/ui/components/Text.js +19 -0
- package/app/extensions/ui/components/TextField.js +30 -0
- package/app/extensions/ui/guide.md +7 -0
- package/app/extensions/ui/index.ts +271 -0
- package/app/extensions/ui/skills/agent-apps/SKILL.md +91 -0
- package/app/extensions/ui/skills/ui-check/SKILL.md +71 -0
- package/app/extensions/ui/skills/ui-check/scripts/check.mjs +306 -0
- package/app/extensions/ui/skills/ui-design/SKILL.md +123 -0
- package/app/extensions/ui/voice.js +114 -0
- package/app/extensions/ui_history/browser.js +97 -0
- package/app/extensions/ui_history/index.ts +109 -0
- package/app/extensions/versions/browser.js +58 -0
- package/app/extensions/versions/index.ts +168 -0
- package/app/extensions/viewer/README.md +12 -0
- package/app/extensions/viewer/browser.js +322 -0
- package/app/extensions/viewer/guide.md +5 -0
- package/app/extensions/viewer/index.ts +209 -0
- package/app/mcp.json +3 -0
- package/app/skills/plugin-map/SKILL.md +49 -0
- package/app/skills/write-skill/SKILL.md +57 -0
- package/app/ui/apps/.keep +0 -0
- package/bin/chalky.mjs +11 -0
- package/node_modules/xlsx/CHANGELOG.md +361 -0
- package/node_modules/xlsx/LICENSE +201 -0
- package/node_modules/xlsx/README.md +41 -0
- package/node_modules/xlsx/bin/xlsx.njs +310 -0
- package/node_modules/xlsx/bower.json +22 -0
- package/node_modules/xlsx/dist/LICENSE +201 -0
- package/node_modules/xlsx/dist/cpexcel.d.ts +39 -0
- package/node_modules/xlsx/dist/cpexcel.full.mjs +1502 -0
- package/node_modules/xlsx/dist/cpexcel.js +1506 -0
- package/node_modules/xlsx/dist/shim.min.js +2 -0
- package/node_modules/xlsx/dist/xlsx.core.min.js +18 -0
- package/node_modules/xlsx/dist/xlsx.core.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.extendscript.js +28259 -0
- package/node_modules/xlsx/dist/xlsx.full.min.js +24 -0
- package/node_modules/xlsx/dist/xlsx.full.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.js +10 -0
- package/node_modules/xlsx/dist/xlsx.mini.min.map +1 -0
- package/node_modules/xlsx/dist/xlsx.zahl.js +4 -0
- package/node_modules/xlsx/dist/xlsx.zahl.mjs +2 -0
- package/node_modules/xlsx/dist/zahl.d.ts +4 -0
- package/node_modules/xlsx/package.json +183 -0
- package/node_modules/xlsx/types/index.d.ts +1065 -0
- package/node_modules/xlsx/types/tsconfig.json +15 -0
- package/node_modules/xlsx/xlsx.js +28103 -0
- package/node_modules/xlsx/xlsx.mjs +28228 -0
- package/node_modules/xlsx/xlsxworker.js +14 -0
- package/package.json +42 -4
- package/server.ts +275 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 weihanfeng
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Chalky
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Chalky is a workspace where AI agents build what you see. You say what you want, the agents put a working
|
|
4
|
+
first version on the page (forms, tables, dashboards, small apps), and then you point at things on it, draw on
|
|
5
|
+
it and leave notes, and they change exactly that. The conversation is a side panel; what they build fills the
|
|
6
|
+
screen.
|
|
7
|
+
|
|
8
|
+
The agents run on [pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent). Everything they are made of
|
|
9
|
+
is pi extensions they can edit themselves, the page included. Every change is versioned, and
|
|
10
|
+
`http://127.0.0.1:3000/recover` takes you back to a version that worked if an agent breaks the page.
|
|
11
|
+
|
|
12
|
+
## Install and run
|
|
13
|
+
|
|
14
|
+
Needs Node.js 22.19 or newer and an OpenAI-compatible model endpoint (an OpenAI-style `/v1`, a LiteLLM proxy, a
|
|
15
|
+
local server).
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g @chalky/chalky
|
|
19
|
+
MODEL_BASE_URL=https://your-endpoint/v1 MODEL_API_KEY=... MODEL_NAME=your-model chalky
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then open http://127.0.0.1:3000.
|
|
23
|
+
|
|
24
|
+
| Variable | |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `MODEL_BASE_URL` | the endpoint, up to `/v1` |
|
|
27
|
+
| `MODEL_API_KEY` | its key |
|
|
28
|
+
| `MODEL_NAME` | the model (default: `default`) |
|
|
29
|
+
| `MODEL_THINKING` | optional thinking level: `minimal`, `low`, `medium`, `high` |
|
|
30
|
+
| `CHALKY_HOME` | where Chalky keeps its things (default: `~/.chalky`) |
|
|
31
|
+
| `UI_HOST` | the address to serve on (default: `127.0.0.1`, this machine only) |
|
|
32
|
+
| `PORT` | the port to serve on (default: `3000`) |
|
|
33
|
+
|
|
34
|
+
## Where things are
|
|
35
|
+
|
|
36
|
+
`~/.chalky` (or `CHALKY_HOME`) holds:
|
|
37
|
+
|
|
38
|
+
- `app/`: the agents' code, copied from the package on the first run. The agents edit it, and each change is
|
|
39
|
+
versioned. A newer version of the package doesn't overwrite it.
|
|
40
|
+
- `state/`: conversations, version history, what the apps keep.
|
|
41
|
+
- `workspace/`: the agents' working folder.
|
|
42
|
+
|
|
43
|
+
## MCP servers
|
|
44
|
+
|
|
45
|
+
Tools from MCP servers go in `~/.chalky/app/mcp.json`, in pi's format (stdio or streamable HTTP; see pi's
|
|
46
|
+
`docs/mcp.md`). Type `/mcp` on the page to choose which servers and tools the agents may use. Keep secrets in
|
|
47
|
+
environment variables and refer to them as `${NAME}` in `headers` or `env`.
|
package/app/SYSTEM.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
You are an agent in Chalky. You build what the user sees: the page is a workspace where what you build (UI
|
|
2
|
+
surfaces and apps) fills the screen, and the conversation is a side panel. The user says what they want; you put a
|
|
3
|
+
working first version on screen; then they point at things on it and leave notes, and you change exactly that.
|
|
4
|
+
- Build, don't describe. A first version they can point at beats questions you could answer by building.
|
|
5
|
+
- Your final reply is the only text they read in full: one or two sentences on what changed.
|
|
6
|
+
- The page around your work is the user's: change it only when asked. Build inside the main space.
|
|
7
|
+
|
|
8
|
+
What follows this: the skills you can load, your working folder (<cwd>: bash runs there), one short section for
|
|
9
|
+
each part of you that has something to say (each comes from an extension), and <paths>: where your folders
|
|
10
|
+
({code}, {work}, {data}), the host and pi's docs are. In these texts and the README.md files, {code}/..., {work}/...
|
|
11
|
+
and {data}/... are paths in those folders. A section that names a README.md is the short version of its
|
|
12
|
+
extension: read that file when the section says, once (it stays in your conversation).
|
|
13
|
+
|
|
14
|
+
Your tools:
|
|
15
|
+
- read to look at files and pictures (not cat or sed); bash for ls, rg, find and running things.
|
|
16
|
+
- edit for precise changes: each edits[].oldText must match the file exactly and only once; keep it as small as
|
|
17
|
+
that allows. Several changes in one file: one edit call with several entries, each matched against the original
|
|
18
|
+
file, none overlapping. An edit whose newText equals its oldText changes nothing and is refused.
|
|
19
|
+
- write only for new files or complete rewrites.
|
|
20
|
+
- Name files by their full path when you mention them.
|
|
21
|
+
|
|
22
|
+
Changing yourself: when the work needs it, you can change any part of you, the page included. You are pi
|
|
23
|
+
extensions, one folder each: {code}/extensions/<name>/. In it, index.ts is `export default function (pi:
|
|
24
|
+
ExtensionAPI) { ... }`, which registers tools (pi.registerTool), prompt sections and event handlers (pi.on); and as
|
|
25
|
+
the extension needs them, guide.md (its short prompt section), README.md (the whole of it), browser.js (its part
|
|
26
|
+
of the page) and skills/. The page itself is the page extension; this text is {code}/SYSTEM.md. Extensions reach
|
|
27
|
+
shared state and the web server through globalThis.chalky: the host, the fixed core under them (don't edit it).
|
|
28
|
+
Read pi's extension docs (<paths>) before writing or changing an extension. After editing, call the reload tool:
|
|
29
|
+
your extensions load again when your turn ends, and your conversation is kept. A changed page or browser.js shows
|
|
30
|
+
when the page reloads, after your turn.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# App data (the appdata extension)
|
|
2
|
+
An App ({code}/ui/apps/<name>/) keeps what the user makes in it (notes, lists, picks) with
|
|
3
|
+
`import agents from "/ui/agents.js"`: `await agents.load()` gives the app's saved data ({} at first),
|
|
4
|
+
`agents.save(data)` replaces it, and `agents.on("app_data", ({app, data}) => ...)` hears every save of any
|
|
5
|
+
app (check app === your app's folder name). Saved on the server in {data}/app-data/<name>.json: it
|
|
6
|
+
survives reloads, is the same in every browser, and you can read it (cat the file) when the user points at
|
|
7
|
+
something in the app. The same app shown in several surfaces shares this data, so one surface's clicks
|
|
8
|
+
update the others at once, with no agent in between. To change it yourself, PUT the whole value:
|
|
9
|
+
curl -X PUT --data @new.json {page}/ui-api/app-data/<name> (the open pages update).
|
|
10
|
+
Not localStorage: that stays in one browser, and you can't see it.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# App data
|
|
2
|
+
An App keeps what the user makes in it (notes, lists, picks) with /ui/agents.js (agents.load, agents.save), on the
|
|
3
|
+
server and shared by every surface showing it; never localStorage (one browser only, and you can't see it).
|
|
4
|
+
Before you build an App that keeps data, read README.md.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// App data: a small shared store for apps (app/ui/apps/<name>/, shown by the App component), saved here, so an
|
|
2
|
+
// app's data outlives the page, is the same in every browser, and the agents can read it (port of the Python
|
|
3
|
+
// appdata plugin). One JSON value per app, in state/app-data/<name>.json. The same app shown in several surfaces
|
|
4
|
+
// shares it: when one saves, every open page and app frame gets an "app_data" event.
|
|
5
|
+
// The app side is app/ui/agents.js: agents.load(), agents.save(data), agents.on("app_data", fn).
|
|
6
|
+
// GET /ui-api/app-data/<name> the app's data ({} if none yet)
|
|
7
|
+
// PUT /ui-api/app-data/<name> replace it (JSON, at most MAX_BYTES); event "app_data" {app, data}
|
|
8
|
+
// Whole value replaced each time: the last save wins (keep one app's data in one value). A version check (refuse a
|
|
9
|
+
// save made from an older value) is the fix if lost saves show up; not added yet.
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const host = (globalThis as any).chalky;
|
|
15
|
+
const DIR = path.join(host.state, "app-data");
|
|
16
|
+
const MAX_BYTES = 1024 * 1024;
|
|
17
|
+
const json = (res: any, v: unknown, status = 200) => res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(v));
|
|
18
|
+
|
|
19
|
+
export default function (pi: ExtensionAPI) {
|
|
20
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
|
|
21
|
+
.replace("README.md", path.join(import.meta.dirname, "README.md"));
|
|
22
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.appdata = guide; });
|
|
23
|
+
|
|
24
|
+
host.routes.set("/ui-api/app-data/", (req: any, res: any) => {
|
|
25
|
+
const name = decodeURIComponent(new URL(req.url, "http://x").pathname.slice("/ui-api/app-data/".length));
|
|
26
|
+
if (!/^[\w-]+$/.test(name)) return json(res, { error: "app names are letters, digits, _ and -" }, 400);
|
|
27
|
+
const p = path.join(DIR, name + ".json");
|
|
28
|
+
if (req.method === "GET") {
|
|
29
|
+
if (!fs.existsSync(p)) return json(res, {});
|
|
30
|
+
return res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" }).end(fs.readFileSync(p));
|
|
31
|
+
}
|
|
32
|
+
if (req.method !== "PUT") return res.writeHead(405).end();
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
let size = 0;
|
|
35
|
+
req.on("data", (c: Buffer) => { size += c.length; if (size <= MAX_BYTES) chunks.push(c); });
|
|
36
|
+
req.on("end", () => {
|
|
37
|
+
if (size > MAX_BYTES) return json(res, { error: `bad app name, or over ${MAX_BYTES / 1024} KB` }, 400);
|
|
38
|
+
let data;
|
|
39
|
+
try { data = JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch (e: any) { return json(res, { error: `not JSON: ${e.message}` }, 400); }
|
|
40
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
41
|
+
fs.writeFileSync(p + ".tmp", JSON.stringify(data));
|
|
42
|
+
fs.renameSync(p + ".tmp", p);
|
|
43
|
+
host.broadcast("app_data", { app: name, data });
|
|
44
|
+
json(res, { saved: true });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Doodles (the doodle extension)
|
|
2
|
+
The user can draw on the page (lines, arrows, circles, words in handwriting), click through it while drawing,
|
|
3
|
+
then send it all with a few words: "[doodles on the UI] <their words>", then the steps, in order. Each step
|
|
4
|
+
is a picture of the screen as they saw it, with their drawing on it (cropped to the drawing; the pictures
|
|
5
|
+
are attached to the message, in step order), which part of the page it shows (main-space px), the words they
|
|
6
|
+
wrote on it (exactly), then what they clicked to get to the next step. The drawing is only in the picture.
|
|
7
|
+
- Read it as a story: step 1 is how it is and what they marked; their clicks lead to step 2; and so on. A
|
|
8
|
+
step with nothing drawn after clicks is how the screen looked after them: often the point ("after Save,
|
|
9
|
+
this should show the date").
|
|
10
|
+
- Look at every picture closely: the drawing is the message. A line from A to B links them ("this goes
|
|
11
|
+
there", "this feeds that"); a circle or a box points at the thing to change; a cross removes; handwriting
|
|
12
|
+
says what. Find what a mark is on from the picture (its text, its look) and the page you know.
|
|
13
|
+
- To place something where they drew (on empty space): the picture's area maps to the page, so a mark's
|
|
14
|
+
spot in the picture is a spot on the page; ui_render(..., place=<that box>).
|
|
15
|
+
- If the drawing could mean two quite different things, propose one in a line and ask; otherwise do it.
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
// Browser half of the doodle extension (Python's doodle.js, unchanged but for this header): 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 surfaces 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 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
|
+
// Enter in the message line (also with the marker off) sends the steps, the drawing still on the page and
|
|
15
|
+
// the words typed there, together: doodle_send, to the agent picked in the chat.
|
|
16
|
+
// Things with a drag of their own keep it: a surface's label (moves it), its resize corner, lists to pick from,
|
|
17
|
+
// sliders, draggable things, frames; text boxes don't (a drag that starts on one draws). Ctrl+Z takes back
|
|
18
|
+
// the last mark; Esc turns the tools off (ink stays).
|
|
19
|
+
// Pictures: the tab is shared once (the screen extension), when the marker or Text is first turned on; without
|
|
20
|
+
// 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.
|
|
21
|
+
// Ink and words live in the main space (slot "top"), in main-space px, so they scroll with the surfaces.
|
|
22
|
+
export default async function (page) {
|
|
23
|
+
const area = page.slot("top");
|
|
24
|
+
const form = document.getElementById("f"), input = document.getElementById("text");
|
|
25
|
+
const SVG = "http://www.w3.org/2000/svg", INK = "#e5397a";
|
|
26
|
+
// The cursor: a marker (white edge, so it shows on dark too); its tip (3, 21) is where the line starts.
|
|
27
|
+
const PEN = `url("data:image/svg+xml,${encodeURIComponent(
|
|
28
|
+
`<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" ` +
|
|
29
|
+
`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`;
|
|
30
|
+
const RUB = `url("data:image/svg+xml,${encodeURIComponent( // the eraser cursor; its bottom-left edge (5, 20) rubs
|
|
31
|
+
`<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` +
|
|
32
|
+
`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"/>` +
|
|
33
|
+
`<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`;
|
|
34
|
+
const CURSORS = `
|
|
35
|
+
.doodling, .doodling * { cursor: ${PEN}; }
|
|
36
|
+
.doodling :is(button, a, select, summary, label, [onclick], [role="button"], [role="tab"], .surface-label) { cursor: pointer; }
|
|
37
|
+
.doodling :is(input, textarea, [contenteditable="true"]) { cursor: text; }
|
|
38
|
+
.doodling .surface-resize { cursor: nwse-resize; }
|
|
39
|
+
.doodle-text:not(.doodling), .doodle-text:not(.doodling) * { cursor: text !important; }
|
|
40
|
+
.doodle-erase, .doodle-erase * { cursor: ${RUB} !important; }`;
|
|
41
|
+
const style = document.createElement("style");
|
|
42
|
+
style.textContent = CURSORS + `
|
|
43
|
+
#top:is(.doodling, .doodle-text, .doodle-erase) { z-index: 11; } /* above the messages panel (10), so lines can go there too */
|
|
44
|
+
#top > svg.doodle-ink { position: absolute; left: 0; top: 0; width: 1px; height: 1px; overflow: visible; z-index: 100001; pointer-events: none; }
|
|
45
|
+
#top > svg.doodle-ink path { fill: none; stroke: ${INK}; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
|
|
46
|
+
#top > .doodle-note { position: absolute; z-index: 100001; font: 20px/1.2 "Segoe Print", "Bradley Hand", "Comic Sans MS", cursive; color: ${INK};
|
|
47
|
+
white-space: pre; outline: none; pointer-events: none; padding: 0 4px; min-width: 8px; }
|
|
48
|
+
#top > .doodle-note:empty::before { content: "type here…"; opacity: .5; }
|
|
49
|
+
#top > .doodle-note[contenteditable="true"] { pointer-events: auto; background: rgba(229, 57, 122, .1); border-radius: 6px; caret-color: ${INK}; }
|
|
50
|
+
#f .doodle-strip { display: flex; align-items: center; gap: 4px; flex: none; max-width: 45%; overflow-x: auto; }
|
|
51
|
+
#f .doodle-strip:empty { display: none; }
|
|
52
|
+
#f .doodle-chip { position: relative; flex: none; min-width: 24px; height: 24px; padding: 0 7px; box-sizing: border-box; border-radius: 7px;
|
|
53
|
+
display: grid; place-items: center; font-size: 12px; font-weight: 600; color: ${INK}; border: 1.5px solid ${INK};
|
|
54
|
+
background: rgba(229, 57, 122, .08); cursor: default; }
|
|
55
|
+
#f .doodle-chip.now { border-style: dashed; background: none; font-weight: 500; }
|
|
56
|
+
#f .doodle-chip .x { position: absolute; right: -6px; top: -6px; width: 14px; height: 14px; border-radius: 50%; background: var(--text);
|
|
57
|
+
color: #fff; font-size: 9px; line-height: 14px; text-align: center; display: none; cursor: pointer; }
|
|
58
|
+
#f .doodle-chip:hover .x { display: block; }
|
|
59
|
+
#f .doodle-click { flex: none; font-size: 12px; color: var(--muted); white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; }
|
|
60
|
+
.doodle-peek { position: fixed; z-index: 100; max-width: 360px; padding: 6px; border-radius: 12px; background: var(--glass-strong, #fff);
|
|
61
|
+
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; }
|
|
62
|
+
.doodle-peek img { display: block; max-width: 100%; max-height: 220px; border-radius: 6px; margin-bottom: 4px; }
|
|
63
|
+
#f .doodle-chip:not(.now) { cursor: zoom-in; }
|
|
64
|
+
.doodle-big { position: fixed; inset: 0; z-index: 200; display: grid; place-content: center; justify-items: center; gap: 10px;
|
|
65
|
+
background: rgba(0, 0, 0, .55); cursor: zoom-out; color: #fff; font-size: 13px; }
|
|
66
|
+
.doodle-big div { padding: 4px 12px; border-radius: 999px; background: rgba(0, 0, 0, .6); }
|
|
67
|
+
.doodle-big img { max-width: 92vw; max-height: 84vh; border-radius: 10px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); background: #fff; }`;
|
|
68
|
+
document.head.append(style);
|
|
69
|
+
|
|
70
|
+
const button = document.createElement("button");
|
|
71
|
+
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>`;
|
|
72
|
+
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)";
|
|
73
|
+
const textButton = document.createElement("button");
|
|
74
|
+
textButton.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 7V5h14v2M12 5v14M9 19h6"/></svg>`;
|
|
75
|
+
textButton.title = "Text: click where the words go (Enter to finish); sent with the doodles, Enter in the message line (Esc to stop)";
|
|
76
|
+
const eraseButton = document.createElement("button");
|
|
77
|
+
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` +
|
|
78
|
+
`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>`;
|
|
79
|
+
eraseButton.title = "Eraser: press or drag over lines, words or unsent frames to take them away (Esc to stop)";
|
|
80
|
+
page.slot("status").append(button, textButton, eraseButton);
|
|
81
|
+
const strip = Object.assign(document.createElement("div"), { className: "doodle-strip" });
|
|
82
|
+
form.insertBefore(strip, input);
|
|
83
|
+
const peek = Object.assign(document.createElement("div"), { className: "doodle-peek", hidden: true });
|
|
84
|
+
const big = Object.assign(document.createElement("div"), { className: "doodle-big", hidden: true }); // a step's picture, big
|
|
85
|
+
big.onclick = () => { big.hidden = true; };
|
|
86
|
+
document.addEventListener("keydown", (e) => { if (e.key === "Escape") big.hidden = true; });
|
|
87
|
+
document.body.append(peek, big);
|
|
88
|
+
|
|
89
|
+
let drawing = false, texting = false, erasing = false, on = false; // which tools are on; on: any of them
|
|
90
|
+
const docs = new Set([document]); // this page and every App frame's page: they all get the cursor
|
|
91
|
+
const classes = (el) => {
|
|
92
|
+
el.classList.toggle("doodling", drawing);
|
|
93
|
+
el.classList.toggle("doodle-text", texting);
|
|
94
|
+
el.classList.toggle("doodle-erase", erasing);
|
|
95
|
+
};
|
|
96
|
+
const set = (tool, v) => { // tool: "doodle" | "text" | "erase"
|
|
97
|
+
if (tool === "doodle") drawing = v; else if (tool === "text") texting = v; else erasing = v;
|
|
98
|
+
on = drawing || texting || erasing;
|
|
99
|
+
button.classList.toggle("on", drawing);
|
|
100
|
+
textButton.classList.toggle("on", texting);
|
|
101
|
+
eraseButton.classList.toggle("on", erasing);
|
|
102
|
+
classes(area);
|
|
103
|
+
for (const d of docs) if (d !== document && d.documentElement) classes(d.documentElement);
|
|
104
|
+
if (v) {
|
|
105
|
+
if (tool !== "erase") share();
|
|
106
|
+
document.dispatchEvent(new CustomEvent("tool-on", { detail: tool }));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const allOff = () => { for (const t of ["doodle", "text", "erase"]) set(t, false); };
|
|
110
|
+
button.onclick = () => set("doodle", !drawing);
|
|
111
|
+
textButton.onclick = () => set("text", !texting);
|
|
112
|
+
eraseButton.onclick = () => set("erase", !erasing);
|
|
113
|
+
document.addEventListener("tool-on", (e) => { // one tool at a time, except the marker and Text, which work together
|
|
114
|
+
const t = e.detail, pair = t === "doodle" || t === "text";
|
|
115
|
+
if (drawing && t !== "doodle" && !pair) set("doodle", false);
|
|
116
|
+
if (texting && t !== "text" && !pair) set("text", false);
|
|
117
|
+
if (erasing && t !== "erase") set("erase", false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ---- pictures: the tab, shared once by the screen plugin (none without it: steps go as words) ----
|
|
121
|
+
let screen = null;
|
|
122
|
+
page.use("screen").then((s) => { screen = s; });
|
|
123
|
+
const share = () => screen?.share();
|
|
124
|
+
const frame = () => screen?.frame() ?? null; // the screen right now, as a canvas (null without a shared tab)
|
|
125
|
+
const picture = (full, box) => screen.picture(full, box); // crop a frame to box (window px) and upload it: {path, url}
|
|
126
|
+
|
|
127
|
+
// ---- where things are ----
|
|
128
|
+
const inMain = (x, y) => { // window px -> main-space px (as point.js)
|
|
129
|
+
const m = area.getBoundingClientRect();
|
|
130
|
+
return { x: x - m.left + area.scrollLeft, y: y - m.top + area.scrollTop };
|
|
131
|
+
};
|
|
132
|
+
const inWindow = (p) => {
|
|
133
|
+
const m = area.getBoundingClientRect();
|
|
134
|
+
return { x: p.x + m.left - area.scrollLeft, y: p.y + m.top - area.scrollTop };
|
|
135
|
+
};
|
|
136
|
+
const short = (s, n = 40) => { s = (s || "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
|
|
137
|
+
|
|
138
|
+
// ---- marks: lines and words ----
|
|
139
|
+
const ink = document.createElementNS(SVG, "svg"); // in the main space only while it has marks (#top:empty shows the welcome)
|
|
140
|
+
ink.classList.add("doodle-ink");
|
|
141
|
+
let marks = []; // [{el, points (main px), text (words only)}], oldest first
|
|
142
|
+
const show = () => { if (marks.some((m) => m.el.localName === "path" || m.el.localName === "g")) area.append(ink); else ink.remove(); render(); };
|
|
143
|
+
const f = (n) => n.toFixed(1);
|
|
144
|
+
const smooth = (pts) => { // through the midpoints, so a quick line isn't a row of corners
|
|
145
|
+
let d = `M${f(pts[0].x)} ${f(pts[0].y)}`;
|
|
146
|
+
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)}`;
|
|
147
|
+
return d + ` L${f(pts.at(-1).x)} ${f(pts.at(-1).y)}`;
|
|
148
|
+
};
|
|
149
|
+
function note(p, first) { // words in handwriting at p (main px)
|
|
150
|
+
const n = Object.assign(document.createElement("div"), { className: "doodle-note", contentEditable: "true", textContent: first });
|
|
151
|
+
Object.assign(n.style, { left: `${p.x}px`, top: `${p.y - 14}px` });
|
|
152
|
+
area.append(n);
|
|
153
|
+
n.focus();
|
|
154
|
+
getSelection().selectAllChildren(n);
|
|
155
|
+
getSelection().collapseToEnd();
|
|
156
|
+
let done = false;
|
|
157
|
+
const finish = () => {
|
|
158
|
+
if (done) return;
|
|
159
|
+
done = true;
|
|
160
|
+
n.contentEditable = "false";
|
|
161
|
+
const t = n.textContent.trim();
|
|
162
|
+
if (!t) { n.remove(); return; }
|
|
163
|
+
marks.push({ el: n, points: [p, { x: p.x + n.offsetWidth, y: p.y + n.offsetHeight - 14 }], text: t });
|
|
164
|
+
show();
|
|
165
|
+
};
|
|
166
|
+
n.onkeydown = (e) => {
|
|
167
|
+
e.stopPropagation();
|
|
168
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); n.blur(); }
|
|
169
|
+
if (e.key === "Escape") { n.textContent = ""; n.blur(); }
|
|
170
|
+
};
|
|
171
|
+
n.onblur = finish;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- erasing: a mark goes whole when the eraser passes over it ----
|
|
175
|
+
function eraseAt(p) { // p: main px
|
|
176
|
+
const left = marks.filter((m) => {
|
|
177
|
+
if (m.text) { // words: their box
|
|
178
|
+
const x = m.el.offsetLeft, y = m.el.offsetTop;
|
|
179
|
+
return !(p.x >= x - 6 && p.x <= x + m.el.offsetWidth + 6 && p.y >= y - 6 && p.y <= y + m.el.offsetHeight + 6);
|
|
180
|
+
}
|
|
181
|
+
return !m.points.some((q) => Math.hypot(q.x - p.x, q.y - p.y) < 12); // a line: near any of its points
|
|
182
|
+
});
|
|
183
|
+
if (left.length !== marks.length) {
|
|
184
|
+
for (const m of marks) if (!left.includes(m)) m.el.remove();
|
|
185
|
+
marks = left;
|
|
186
|
+
show();
|
|
187
|
+
}
|
|
188
|
+
document.dispatchEvent(new CustomEvent("erase", { detail: p })); // other tools' things (frames: unsent frames)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---- steps ----
|
|
192
|
+
let steps = [], first = []; // steps: [{marks: [text], then: [click], shot: {path, url} | null, ready}]
|
|
193
|
+
let grabbed = null; // the screen when the last button went down (a click may end a step with it)
|
|
194
|
+
function bounds(pts) { // window px, with room around the drawing, inside the window
|
|
195
|
+
const w = pts.map(inWindow), pad = 60;
|
|
196
|
+
let x0 = Math.min(...w.map((p) => p.x)) - pad, x1 = Math.max(...w.map((p) => p.x)) + pad;
|
|
197
|
+
let y0 = Math.min(...w.map((p) => p.y)) - pad, y1 = Math.max(...w.map((p) => p.y)) + pad;
|
|
198
|
+
if (x1 - x0 < 480) { const c = (x0 + x1) / 2; x0 = c - 240; x1 = c + 240; }
|
|
199
|
+
if (y1 - y0 < 300) { const c = (y0 + y1) / 2; y0 = c - 150; y1 = c + 150; }
|
|
200
|
+
x0 = Math.max(0, x0); y0 = Math.max(0, y0); x1 = Math.min(innerWidth, x1); y1 = Math.min(innerHeight, y1);
|
|
201
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
202
|
+
}
|
|
203
|
+
function keep(full) { // the marks on the page become a step (full: the screen to crop its picture from)
|
|
204
|
+
const box = marks.length ? bounds(marks.flatMap((m) => m.points)) : { x: 0, y: 0, w: innerWidth, h: innerHeight };
|
|
205
|
+
const at = inMain(box.x, box.y); // the part of the main space the picture shows, so the agent can place things
|
|
206
|
+
const step = { drawn: marks.length > 0, words: marks.filter((m) => m.text).map((m) => m.text), then: [], shot: null,
|
|
207
|
+
area: { x: Math.round(at.x), y: Math.round(at.y), width: Math.round(box.w), height: Math.round(box.h) } };
|
|
208
|
+
step.ready = full ? picture(full, box).then((s) => { step.shot = s; }, () => {}) : Promise.resolve();
|
|
209
|
+
steps.push(step);
|
|
210
|
+
for (const m of marks) m.el.remove();
|
|
211
|
+
marks = [];
|
|
212
|
+
show();
|
|
213
|
+
}
|
|
214
|
+
function clicked(t, surface) { // a click, as words
|
|
215
|
+
const label = short(t.getAttribute("aria-label") || t.title || t.textContent || t.value || t.localName, 30);
|
|
216
|
+
const row = t.closest("tr, li");
|
|
217
|
+
const rowText = row && row !== t ? short((row.innerText || row.textContent).replace(t.innerText || t.textContent, ""), 30) : "";
|
|
218
|
+
return `clicked "${label}"${rowText ? ` on the "${rowText}" row` : ""}${surface ? ` (surface ${surface})` : ""}`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- presses: this page and every App frame's page ----
|
|
222
|
+
// Text boxes draw too (a click still focuses them, a double click still selects a word): circling a notes box
|
|
223
|
+
// is a common thing to draw.
|
|
224
|
+
const OWN_DRAG = ".surface-label, .surface-resize, select, input[type='range'], [draggable='true'], .frame, .doodle-note";
|
|
225
|
+
const CLICKABLE = "button, a, summary, select, label, [onclick], [role='button'], [role='tab'], [role='checkbox'], input[type='checkbox'], input[type='radio']";
|
|
226
|
+
let press = null, line = null, swallow = false, over = null; // over: the pointer, in window px, while it is over the page
|
|
227
|
+
function hook(win, frameEl) {
|
|
228
|
+
const doc = win.document;
|
|
229
|
+
if (frameEl) {
|
|
230
|
+
if (docs.has(doc)) return;
|
|
231
|
+
docs.add(doc);
|
|
232
|
+
const s = doc.createElement("style");
|
|
233
|
+
s.textContent = CURSORS;
|
|
234
|
+
doc.head.append(s);
|
|
235
|
+
classes(doc.documentElement);
|
|
236
|
+
}
|
|
237
|
+
const offset = () => {
|
|
238
|
+
if (!frameEl) return { x: 0, y: 0 };
|
|
239
|
+
const r = frameEl.getBoundingClientRect();
|
|
240
|
+
return { x: r.left + frameEl.clientLeft, y: r.top + frameEl.clientTop };
|
|
241
|
+
};
|
|
242
|
+
const root = frameEl ? doc : area;
|
|
243
|
+
root.addEventListener("pointerdown", (e) => {
|
|
244
|
+
if (!on || e.button !== 0) return;
|
|
245
|
+
const o = offset();
|
|
246
|
+
if (erasing) { // the press is for the eraser, not for what's under it
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
press = { erase: true, win };
|
|
249
|
+
eraseAt(inMain(e.clientX + o.x, e.clientY + o.y));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
grabbed = marks.length ? frame() : null; // before the click acts
|
|
253
|
+
if (e.target.closest?.(texting && !drawing ? ".doodle-note" : OWN_DRAG)) return;
|
|
254
|
+
if (texting) e.preventDefault(); // the press is for the words (or a line), not for what's under it
|
|
255
|
+
press = { x: e.clientX + o.x, y: e.clientY + o.y, id: e.pointerId, target: e.target, win, o, text: texting };
|
|
256
|
+
}, true);
|
|
257
|
+
win.addEventListener("pointermove", (e) => {
|
|
258
|
+
const o = offset(), x = e.clientX + o.x, y = e.clientY + o.y;
|
|
259
|
+
over = frameEl || e.target.closest?.("#top") ? { x, y } : null;
|
|
260
|
+
if (press?.erase && press.win === win) { win.getSelection().removeAllRanges(); eraseAt(inMain(x, y)); return; }
|
|
261
|
+
if (!press || press.win !== win || !drawing) return;
|
|
262
|
+
if (!line) {
|
|
263
|
+
if (Math.hypot(x - press.x, y - press.y) < 6) return; // not yet a line: may still be a click
|
|
264
|
+
line = { el: document.createElementNS(SVG, "g"), points: [inMain(press.x, press.y)] };
|
|
265
|
+
line.el.append(document.createElementNS(SVG, "path"));
|
|
266
|
+
ink.append(line.el);
|
|
267
|
+
marks.push(line);
|
|
268
|
+
show();
|
|
269
|
+
try { press.target.setPointerCapture(press.id); } catch {}
|
|
270
|
+
const active = press.win.document.activeElement; // a text box the press focused: typing now writes words, not into it
|
|
271
|
+
if (active?.matches?.("input, textarea, [contenteditable='true']")) active.blur();
|
|
272
|
+
}
|
|
273
|
+
win.getSelection().removeAllRanges(); // the first few px may have started a text selection
|
|
274
|
+
const p = inMain(x, y), last = line.points.at(-1);
|
|
275
|
+
if (Math.hypot(p.x - last.x, p.y - last.y) < 1.5) return;
|
|
276
|
+
line.points.push(p);
|
|
277
|
+
line.el.firstChild.setAttribute("d", smooth(line.points));
|
|
278
|
+
});
|
|
279
|
+
win.addEventListener("pointerup", () => {
|
|
280
|
+
if (press?.erase) { swallow = true; setTimeout(() => { swallow = false; }); } // the eraser's press isn't a click
|
|
281
|
+
if (press?.text && !line) { // Text: words where the press was (a press that drew a line was for the marker)
|
|
282
|
+
note(inMain(press.x, press.y), "");
|
|
283
|
+
swallow = true;
|
|
284
|
+
setTimeout(() => { swallow = false; });
|
|
285
|
+
}
|
|
286
|
+
if (line) {
|
|
287
|
+
if (line.points.length < 3) { marks.pop(); line.el.remove(); }
|
|
288
|
+
swallow = true; // the click that ends a line isn't a click
|
|
289
|
+
setTimeout(() => { swallow = false; });
|
|
290
|
+
show();
|
|
291
|
+
}
|
|
292
|
+
press = line = null;
|
|
293
|
+
});
|
|
294
|
+
root.addEventListener("click", (e) => {
|
|
295
|
+
if (swallow) { e.stopPropagation(); e.preventDefault(); swallow = false; return; }
|
|
296
|
+
const t = on && e.target.closest?.(CLICKABLE);
|
|
297
|
+
if (!t || t.closest(".doodle-note")) return;
|
|
298
|
+
if (marks.length) keep(grabbed); // the drawing so far, as the screen was when the button went down
|
|
299
|
+
(steps.length ? steps.at(-1).then : first).push(clicked(t, (frameEl || t).closest("[data-surface]")?.dataset.surface));
|
|
300
|
+
render();
|
|
301
|
+
}, true);
|
|
302
|
+
root.addEventListener("dragstart", (e) => { if (on) e.preventDefault(); }, true); // links and pictures don't get dragged away
|
|
303
|
+
doc.addEventListener("keydown", onKey);
|
|
304
|
+
}
|
|
305
|
+
hook(window, null);
|
|
306
|
+
const hookFrame = (fr) => {
|
|
307
|
+
const go = () => { try { if (fr.contentDocument?.readyState !== "loading") hook(fr.contentWindow, fr); } catch {} }; // other origins: not ours
|
|
308
|
+
fr.addEventListener("load", go);
|
|
309
|
+
go();
|
|
310
|
+
};
|
|
311
|
+
area.querySelectorAll("iframe").forEach(hookFrame);
|
|
312
|
+
new MutationObserver((list) => {
|
|
313
|
+
for (const r of list) for (const n of r.addedNodes) {
|
|
314
|
+
if (n.localName === "iframe") hookFrame(n);
|
|
315
|
+
else n.querySelectorAll?.("iframe").forEach(hookFrame);
|
|
316
|
+
}
|
|
317
|
+
}).observe(area, { childList: true, subtree: true });
|
|
318
|
+
|
|
319
|
+
function onKey(e) {
|
|
320
|
+
if (e.target.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
|
321
|
+
if (e.key === "Escape" && on) { allOff(); return; }
|
|
322
|
+
if (!on || erasing) return;
|
|
323
|
+
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
|
324
|
+
const m = marks.pop();
|
|
325
|
+
if (m) { m.el.remove(); show(); e.preventDefault(); }
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (e.key.length === 1 && e.key !== " " && !e.ctrlKey && !e.metaKey && !e.altKey && over) {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
note(inMain(over.x, over.y), e.key);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ---- the strip in the message line ----
|
|
335
|
+
function render() {
|
|
336
|
+
strip.replaceChildren();
|
|
337
|
+
steps.forEach((s, i) => {
|
|
338
|
+
const chip = Object.assign(document.createElement("span"), { className: "doodle-chip", textContent: i + 1 });
|
|
339
|
+
const x = Object.assign(document.createElement("span"), { className: "x", textContent: "✕", title: "Drop this step" });
|
|
340
|
+
x.onclick = () => { peek.hidden = true; steps.splice(i, 1); render(); };
|
|
341
|
+
chip.append(x);
|
|
342
|
+
const words = s.words.map((w) => `"${w}"`).join(" ");
|
|
343
|
+
chip.title = s.shot ? "Click to see it big" : "No picture: the tab isn't shared";
|
|
344
|
+
chip.onmouseenter = () => { // a small preview above the chip
|
|
345
|
+
peek.replaceChildren();
|
|
346
|
+
const place = () => {
|
|
347
|
+
const r = chip.getBoundingClientRect(), p = peek.getBoundingClientRect();
|
|
348
|
+
Object.assign(peek.style, { left: `${Math.max(8, Math.min(innerWidth - p.width - 8, r.left))}px`, top: `${r.top - p.height - 10}px` });
|
|
349
|
+
};
|
|
350
|
+
if (s.shot?.url) peek.append(Object.assign(document.createElement("img"), { src: s.shot.url, onload: place }));
|
|
351
|
+
peek.append(words || (s.shot ? "" : "no picture (the tab isn't shared)"));
|
|
352
|
+
peek.hidden = false;
|
|
353
|
+
place();
|
|
354
|
+
};
|
|
355
|
+
chip.onmouseleave = () => { peek.hidden = true; };
|
|
356
|
+
chip.onclick = (e) => { // the picture big, over the page; a click or Esc closes it
|
|
357
|
+
if (e.target === x || !s.shot?.url) return;
|
|
358
|
+
peek.hidden = true;
|
|
359
|
+
big.replaceChildren(Object.assign(document.createElement("img"), { src: s.shot.url }),
|
|
360
|
+
Object.assign(document.createElement("div"), { textContent: `Step ${i + 1}${words ? ` · ${words}` : ""}` }));
|
|
361
|
+
big.hidden = false;
|
|
362
|
+
};
|
|
363
|
+
strip.append(chip);
|
|
364
|
+
for (const c of s.then) strip.append(Object.assign(document.createElement("span"), { className: "doodle-click", textContent: `→ ${c.replace(/^clicked "([^"]*)".*/, "$1")}`, title: c }));
|
|
365
|
+
});
|
|
366
|
+
if (marks.length) strip.append(Object.assign(document.createElement("span"), { className: "doodle-chip now", textContent: `${marks.length} mark${marks.length > 1 ? "s" : ""}` }));
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- send: Enter in the message line takes the doodles along (chat_ui's own send is for plain words) ----
|
|
370
|
+
document.addEventListener("submit", async (e) => {
|
|
371
|
+
if (e.target !== form || (!steps.length && !marks.length)) return;
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
e.stopPropagation();
|
|
374
|
+
const text = input.value;
|
|
375
|
+
input.value = "";
|
|
376
|
+
if (marks.length) keep(frame());
|
|
377
|
+
else if (steps.at(-1).then.length) keep(frame()); // clicked after the last step: how the screen looks now
|
|
378
|
+
const sent = steps;
|
|
379
|
+
steps = [];
|
|
380
|
+
const before = first;
|
|
381
|
+
first = [];
|
|
382
|
+
render();
|
|
383
|
+
await Promise.all(sent.map((s) => s.ready));
|
|
384
|
+
page.send({ type: "doodle_send", to: document.getElementById("to")?.value, text, first: before,
|
|
385
|
+
steps: sent.map((s) => ({ shot: s.shot?.path || null, area: s.area, drawn: s.drawn, words: s.words, then: s.then })) });
|
|
386
|
+
allOff();
|
|
387
|
+
}, true);
|
|
388
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Doodles
|
|
2
|
+
The user draws on the page and clicks through it, then sends it with a few words: "[doodles on the UI]", with a
|
|
3
|
+
picture for each step.
|
|
4
|
+
- Read the steps in order, as a story; look closely at every picture: the drawing is the message.
|
|
5
|
+
- If it could mean two quite different things, propose one in a line and ask; otherwise do it.
|
|
6
|
+
Before you handle a doodle, read README.md.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Doodle: draw on the page, click through it, and send the whole sequence to an agent (port of the Python doodle
|
|
2
|
+
// plugin; browser.js is its doodle.js). browser.js adds a marker, Text and an eraser to the palette: while the marker
|
|
3
|
+
// is on, a drag draws, typing writes words where the pointer is, and a click works the page as usual. A click after
|
|
4
|
+
// drawing makes the drawing a step: a picture of the screen as the user saw it (from the tab, shared once: the
|
|
5
|
+
// screen extension), cropped to the drawing. Enter in the message line sends the steps and the words typed there,
|
|
6
|
+
// together, to the agent picked in the chat (else agent1), with the pictures attached to the message itself
|
|
7
|
+
// (swarm send's images), in step order.
|
|
8
|
+
// page message doodle_send {to, text, first: [click], steps: [{shot, area, drawn, words: [str], then: [click]}]}
|
|
9
|
+
// (shot: a path from /screen/shot, or null when the tab wasn't shared; area: the part of the main
|
|
10
|
+
// space the picture shows, main px; drawn: anything drawn on it; words: what the user typed on it,
|
|
11
|
+
// exactly; clicks are words browser.js wrote)
|
|
12
|
+
// The drawing itself goes only as the picture: lines aren't put into words (a guess at what a stroke means can be
|
|
13
|
+
// wrong, and the picture shows it).
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const host = (globalThis as any).chalky;
|
|
19
|
+
const s = (n: number) => (n === 1 ? "" : "s");
|
|
20
|
+
|
|
21
|
+
function message(msg: any) {
|
|
22
|
+
const steps: any[] = msg.steps ?? [];
|
|
23
|
+
const shots: string[] = steps.map((st) => st.shot).filter((p) => host.api.screen?.isShot(p));
|
|
24
|
+
const words = String(msg.text ?? "").trim();
|
|
25
|
+
const lines = [`[doodles on the UI] ${words || "(no words: the drawing says it)"}`,
|
|
26
|
+
`${steps.length} step${s(steps.length)}, ${shots.length} picture${s(shots.length)} attached in this order`
|
|
27
|
+
+ (shots.length === steps.length ? "" : " (the tab wasn't shared for the others: words only)") + ":"];
|
|
28
|
+
if (msg.first?.length) lines.push(` first: ${msg.first.join("; ")}`);
|
|
29
|
+
let n = 0;
|
|
30
|
+
steps.forEach((st, i) => {
|
|
31
|
+
const has = shots.includes(st.shot), a = st.area;
|
|
32
|
+
if (has) n++;
|
|
33
|
+
lines.push(`Step ${i + 1}` + (has ? ` (picture ${n})` : " (no picture)")
|
|
34
|
+
+ (has && a ? `, showing x=${a.x} y=${a.y} w=${a.width} h=${a.height} of the page` : "")
|
|
35
|
+
+ (st.drawn ? "" : ": nothing drawn, how the screen looked after those clicks"));
|
|
36
|
+
for (const w of st.words ?? []) lines.push(` words: "${w}"`);
|
|
37
|
+
if (st.then?.length) lines.push(` then: ${st.then.join("; ")}`);
|
|
38
|
+
});
|
|
39
|
+
return { text: lines.join("\n"), shots };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function (pi: ExtensionAPI) {
|
|
43
|
+
const guide = fs.readFileSync(path.join(import.meta.dirname, "guide.md"), "utf8") // short; README.md: the whole of it, read when it says
|
|
44
|
+
.replace("README.md", path.join(import.meta.dirname, "README.md"));
|
|
45
|
+
pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.doodle = guide; });
|
|
46
|
+
|
|
47
|
+
host.pageHandlers.set("doodle", (msg: any) => {
|
|
48
|
+
const swarm = host.api.swarm;
|
|
49
|
+
if (msg.type !== "doodle_send" || !swarm) return;
|
|
50
|
+
const to = msg.to in swarm.status() ? msg.to : "agent1";
|
|
51
|
+
const { text, shots } = message(msg);
|
|
52
|
+
swarm.send("user", to, text, false, shots);
|
|
53
|
+
});
|
|
54
|
+
}
|