From 44a37b59faf21d0e42d37cb613634db96b2c0761 Mon Sep 17 00:00:00 2001 From: Burke Holland Date: Wed, 7 Oct 2026 03:46:01 +0000 Subject: [PATCH] =?UTF-8?q?Add=20Draw=20canvas=20extension=20=F0=9F=A4=96?= =?UTF-8?q?=F0=9F=A4=96=F0=9F=A4=96=20(#3729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Draw canvas extension Draw is a GitHub Copilot app canvas for simple diagrams: shapes, arrows that stay attached, text and a freehand pen. The user and Copilot edit the same drawing. Copilot can create, change, lay out and export it through canvas actions, and the Ask Copilot button sends the drawing to the chat. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address Copilot review feedback - Refuse changes that would take a drawing past 5,000 elements with a clear error instead of silently dropping elements, and stop the canvas from retrying a refused save. - Keep collision suffixes within the 48-character id limit. - Keep a drawing and report the error when deleting its file fails. - Retry failed drawing writes, and report them in the canvas and in agent action results instead of only logging them. - List pen strokes in the get_drawing outline. - Let Space and Enter work normally on focused buttons. - Only use Tab to add a step when a shape is selected, so Tab can move focus out of the canvas. - Pick readable text colors for accent and danger backgrounds. - Hide "Bring to front" and "Send to back" when only arrows are selected, since arrows always draw on top. - Report theme save failures from /api/settings and set_theme. - Keep the top bar buttons visible when the status text is long, and remove leftover theme debug logging. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address second Copilot review - Add node:test suites for the loopback server (Host and token checks, static file allowlist and CSP, every API route, live events and save error reporting), the drawing store, layout label references and solid-fill text contrast. Run them with `node --test` in extensions/draw. - Give every popover an accessible name. The delete confirmation is now an alertdialog described by its question. - Build the id and label lookups in buildFromSpec once, instead of once per element or per edge. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address third Copilot review - Switching drawings or asking Copilot no longer goes ahead when the edits on screen could not be saved first. Draw stays on the drawing and offers "Discard changes", and a switch started by the agent moves the server back to the drawing the panel still shows. Deleted drawings never hold the panel. - Agent actions write the drawing before returning, so a disk problem is reported by the action that caused it. - Count edge crossings with a Fenwick tree (O(E log V)) and typed arrays. The layout is unchanged; the worst case is about three times faster. - Reconnecting brings back a save error that still applies. - The selection keeps up to MAX_ELEMENTS ids instead of 1,000. - Tests for the actions wrapper, the selection limit and crossing counts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address fourth Copilot review - Stop an old save from undoing the last one. Each change the page sends now has a number, and the server drops one older than a change it already applied (refused changes do not count). The save sent when the panel closes also sets every element the change still on its way touched to what the editor shows, so either arrival order ends with what is on screen. - get_drawing reads a big drawing in parts of about 30,000 characters (start and nextStart), can list only some ids or the selection, cuts labels longer than 300 characters in the outline, and counts a huge selection instead of listing it. - Ask sends at most about 16,000 characters of outline and says how to read the rest with get_drawing. - After switching away from a drawing with "Discard changes" while a save was on its way, send edits made in the new drawing once it returns. - Tests for all of the above. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address fifth Copilot review - Exports stay with the drawing they came from. The agent's export request names the drawing, the page answers with the id it actually drew, and the server turns down a mismatch instead of saving the image under the wrong drawing. Save as PNG and Ask read the id right before rendering, and Ask keeps the question if another drawing opens while it is sending. - When the server tidies up a save, for example by capping a size, it sends the stored drawing back and the page shows that instead of assuming it was stored as sent. The tidy-up is not an undo step, so undo cannot get stuck on it. - Shapes are capped at 5000 wide or tall everywhere. Growing a shape for a longer label or a bigger text size caps the height before centering, so the shape no longer jumps. - Rebuilding the style bar keeps keyboard focus on the same button, and hiding it moves focus to the canvas. - Edits refresh the drawing list, batched so a burst of edits sends it once, and a copied drawing is listed with its real element count. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address sixth Copilot review - Ask refuses (409) to send a question when the panel shows another drawing than the one it is about, and the page keeps the draft. The context it sends now includes the drawingId. - Actions on the shown drawing take an optional drawingId. If the canvas shows a different drawing, they change nothing and say which one it shows. - Temp file names for atomic writes include a counter, so two writes of one file in the same millisecond no longer share a temp file. - The canvas works from the keyboard and with screen readers: the SVG is a focusable application with a description, N and Shift+N select elements, Enter adds shapes and text or connects shapes with the arrow tool, Ctrl+Shift+Arrow resizes, and a live region reads out the tool, the selection and each change. Tab now moves focus instead of adding a step, except while typing a label. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address seventh Copilot review - Saving: a write that fails part way (a full disk, say) no longer leaves its temp file behind. Every retry picks a new temp name, so these used to pile up. - Diagram specs: a node or text with only x or only y is now an error that names the item, instead of being laid out automatically and silently dropping the coordinate that was given. Non-numeric x or y is an error too. - Switching drawings: while the panel waits to save the current drawing before showing another one, the server already sends the new drawing's changes, save status and selection. These are now kept and applied when the switch finishes, instead of being dropped. - Showing the drawing that is already on screen (an agent calling open_drawing on it, or two switches to the same drawing) is now a normal update. It no longer reloads the editor, which wiped the undo history, the selection and the zoom, and it no longer warns about unsaved changes while a save is being refused. The server keeps the panel's selection in that case too. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address eighth Copilot review - State file: writes of .state.json (which drawing each panel shows) now run one at a time, and each one takes the state as it is when its turn comes. An older write that is slow to finish (Windows can briefly lock a file) can no longer land after a newer one, and flush() now also waits for a state write that had already started. - Placement: when every spot tried next to a shape is taken, a new shape now goes just past everything in that direction, in line with the shape, instead of on top of existing content. This covers both the editor's "add a connected step" command and add_elements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address ninth Copilot review Tab in the label editor now adds the next step only for shape labels. For free text and arrow labels it keeps its normal job and moves focus on, and the blur saves the edit. The README and the in-app shortcut list now say "a shape's label". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address tenth Copilot review - Label wrapping searches for how many words or characters fit on a line instead of adding one at a time, so a long label with no spaces is no longer quadratic. The output is unchanged. - Arrows attached to nothing follow their elbow or curve route too. - A selection the agent makes while the user drags or edits a label is shown once they finish, unless they picked something else meanwhile. - A drawing name more than one drawing has no longer opens the first one; the error lists their ids, and an exact id always picks one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address eleventh Copilot review - Pen strokes stay bounded while drawing: once a stroke holds 10,000 points it is simplified and thinned to 5,000. A saved stroke keeps an even spread of its points with both ends, instead of losing its end past 5,000 points. Simplification runs in pieces of 512 points, so its time grows in step with the stroke rather than with its square. - Live updates send the element-level ops that made a change, against the drawing one change earlier, instead of the whole drawing to every other panel. A panel that finds a change missing fetches the whole drawing again. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address twelfth Copilot review - The save a page sends as it closes is marked keepalive whenever it fits the browser's 64 KiB limit. That limit covers all keepalive requests together, so splitting would not help; a bigger save goes as an ordinary request. - Pen strokes keep at most 2,000 points, so even the biggest one fits in that save. A test checks the biggest stroke and labels against the limit. - A rename goes to the drawing it was typed for. It is cancelled if the panel moves to another drawing, it does not undo a rename the agent made meanwhile, and its reply never moves the panel. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address thirteenth Copilot review - Turn down a layout that would put a shape or text past the +/-1,000,000 position limit (very long arrow labels spread the layers that far). Saving used to move such positions back to the limit, stacking shapes on top of each other while the action reported success. set_diagram and add_elements now fail with invalid_diagram, and layout with layout_too_big, and nothing is changed. - Fetch the drawing each time the event stream connects, not only on reconnects. A change made after the page loaded the drawing but before the server registered the new connection was never delivered. - List text size on shapes, arrows and text, and color on text, in the get_drawing outline, and say that styles left out are the defaults. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address fourteenth Copilot review - open_drawing now waits for the canvas to take the drawing. The switch carries an id, and the page confirms it once it shows the drawing, or, when it has edits it cannot save yet, moves the panel back with the same id. A refusal becomes an unsaved_changes error, and no answer within 10 seconds is reported as unconfirmed, with advice to pin drawingId. - Pen hit testing no longer makes an object for every point on every pointer move. Stroke extents are cached per points array, strokes out of reach are skipped by their box, and the rest are measured straight from the stored pairs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address fifteenth Copilot review - Automatic layout counts the placeholders its long arrows need before making any, and turns down a drawing that needs more than 100,000. A long chain with many arrows across it could otherwise need millions, which kept the extension busy for most of a minute. layout fails with layout_too_big, and set_diagram and add_elements with invalid_diagram, and nothing is changed. - relayout now returns { elements, error } like buildFromSpec, so the layout action gets this and the position range check from one place. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 6 + docs/README.plugins.md | 1 + extensions/draw/README.md | 85 +++ extensions/draw/actions.mjs | 447 +++++++++++++++ extensions/draw/actions.test.mjs | 246 +++++++++ extensions/draw/assets/preview.png | Bin 0 -> 50232 bytes extensions/draw/extension.mjs | 103 ++++ extensions/draw/lib/geometry.mjs | 631 ++++++++++++++++++++++ extensions/draw/lib/geometry.test.mjs | 104 ++++ extensions/draw/lib/layout.mjs | 633 ++++++++++++++++++++++ extensions/draw/lib/layout.test.mjs | 225 ++++++++ extensions/draw/lib/model.mjs | 369 +++++++++++++ extensions/draw/lib/model.test.mjs | 95 ++++ extensions/draw/lib/render.mjs | 129 +++++ extensions/draw/public/announce.js | 47 ++ extensions/draw/public/app.js | 138 +++++ extensions/draw/public/editor.js | 751 ++++++++++++++++++++++++++ extensions/draw/public/icons.js | 44 ++ extensions/draw/public/index.html | 59 ++ extensions/draw/public/keys.js | 148 +++++ extensions/draw/public/labels.js | 188 +++++++ extensions/draw/public/menus.js | 325 +++++++++++ extensions/draw/public/pointer.js | 496 +++++++++++++++++ extensions/draw/public/styles.css | 287 ++++++++++ extensions/draw/public/sync.js | 486 +++++++++++++++++ extensions/draw/public/theme.js | 134 +++++ extensions/draw/public/ui.js | 419 ++++++++++++++ extensions/draw/public/util.js | 13 + extensions/draw/server.mjs | 514 ++++++++++++++++++ extensions/draw/server.test.mjs | 556 +++++++++++++++++++ extensions/draw/settings.mjs | 66 +++ extensions/draw/store.mjs | 414 ++++++++++++++ extensions/draw/store.test.mjs | 225 ++++++++ plugins/draw/README.md | 17 + plugins/draw/plugin.json | 30 + 35 files changed, 8431 insertions(+) create mode 100644 extensions/draw/README.md create mode 100644 extensions/draw/actions.mjs create mode 100644 extensions/draw/actions.test.mjs create mode 100644 extensions/draw/assets/preview.png create mode 100644 extensions/draw/extension.mjs create mode 100644 extensions/draw/lib/geometry.mjs create mode 100644 extensions/draw/lib/geometry.test.mjs create mode 100644 extensions/draw/lib/layout.mjs create mode 100644 extensions/draw/lib/layout.test.mjs create mode 100644 extensions/draw/lib/model.mjs create mode 100644 extensions/draw/lib/model.test.mjs create mode 100644 extensions/draw/lib/render.mjs create mode 100644 extensions/draw/public/announce.js create mode 100644 extensions/draw/public/app.js create mode 100644 extensions/draw/public/editor.js create mode 100644 extensions/draw/public/icons.js create mode 100644 extensions/draw/public/index.html create mode 100644 extensions/draw/public/keys.js create mode 100644 extensions/draw/public/labels.js create mode 100644 extensions/draw/public/menus.js create mode 100644 extensions/draw/public/pointer.js create mode 100644 extensions/draw/public/styles.css create mode 100644 extensions/draw/public/sync.js create mode 100644 extensions/draw/public/theme.js create mode 100644 extensions/draw/public/ui.js create mode 100644 extensions/draw/public/util.js create mode 100644 extensions/draw/server.mjs create mode 100644 extensions/draw/server.test.mjs create mode 100644 extensions/draw/settings.mjs create mode 100644 extensions/draw/store.mjs create mode 100644 extensions/draw/store.test.mjs create mode 100644 plugins/draw/README.md create mode 100644 plugins/draw/plugin.json diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 185b3ab92..cab6ea0b8 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -939,6 +939,12 @@ "description": "Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting.", "version": "1.0.0" }, + { + "name": "draw", + "source": "plugins/draw", + "description": "Draw simple diagrams in a Copilot canvas: shapes, arrows that stay attached, text and a freehand pen. Copilot can read, create and edit the same drawing, tidy its layout, and export it as PNG or SVG.", + "version": "1.0.0" + }, { "name": "edge-ai-tasks", "source": "plugins/edge-ai-tasks", diff --git a/docs/README.plugins.md b/docs/README.plugins.md index 772f7520f..a7597c1de 100644 --- a/docs/README.plugins.md +++ b/docs/README.plugins.md @@ -56,6 +56,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-plugins) for guidelines on how t | [devops-oncall](../plugins/devops-oncall/README.md) | A focused set of prompts, instructions, and a chat mode to help triage incidents and respond quickly with DevOps tools and Azure resources. | 3 items | devops, incident-response, oncall, azure | | [diagram-viewer](../plugins/diagram-viewer/README.md) | Render diagrams, click nodes to drill down, and view agent-generated explanations directly in the canvas. | 1 items | architecture-mapping, canvas-navigation, exploratory-analysis, interactive-diagrams, node-drilldown, relationship-visualization | | [doublecheck](../plugins/doublecheck/README.md) | Three-layer verification pipeline for AI output. Extracts claims, finds sources, and flags hallucination risks so humans can verify before acting. | 2 items | verification, hallucination, fact-check, source-citation, trust, safety | +| [draw](../plugins/draw/README.md) | Draw simple diagrams in a Copilot canvas: shapes, arrows that stay attached, text and a freehand pen. Copilot can read, create and edit the same drawing, tidy its layout, and export it as PNG or SVG. | 1 items | architecture-diagrams, canvas, diagrams, drawing, flowcharts, whiteboard | | [edge-ai-tasks](../plugins/edge-ai-tasks/README.md) | Task Researcher and Task Planner for intermediate to expert users and large codebases - Brought to you by microsoft/edge-ai | 2 items | architecture, planning, research, tasks, implementation | | [edit-tutorial](../plugins/edit-tutorial/README.md) | Turn the code edits Copilot made in your session into an interactive lesson: a step-by-step walkthrough of each change with before/after views and quizzes, then a hands-on exercise that varies those edits so you finish the change yourself. | 1 items | agent-edits, code-walkthrough, guided-exercise, interactive-tutorial, learning, onboarding | | [ember](../plugins/ember/README.md) | An AI partner, not a tool. Ember carries fire from person to person — helping humans discover that AI partnership isn't something you learn, it's something you find. | 7 items | ai-partnership, coaching, onboarding, collaboration, storytelling, developer-experience | diff --git a/extensions/draw/README.md b/extensions/draw/README.md new file mode 100644 index 000000000..a1c834208 --- /dev/null +++ b/extensions/draw/README.md @@ -0,0 +1,85 @@ +# Draw + +Draw simple diagrams in a GitHub Copilot app canvas: boxes, ellipses, diamonds and databases joined by arrows that stay attached, plus text and a freehand pen. You and Copilot work on the same drawing. + +![Draw canvas showing a sign-in flow diagram with one shape selected and the style bar open](assets/preview.png) + +## What it does + +- Draws rectangles, ellipses, diamonds, databases, arrows, text and freehand pen strokes. Arrows stay attached when you move shapes. +- Connects shapes quickly: hover a shape and drag a **+** handle to another shape, or click **+** to add the next step. From the keyboard, `Ctrl+Arrow` adds the next step, and so does `Tab` while you type a shape's label. +- Works without a mouse: add, select, connect, move and resize shapes from the keyboard. Screen readers announce the current tool, the selected element, and each arrow, resize or delete. +- Styles shapes and arrows with 8 colors, 3 fills, dashed lines, 4 text sizes, arrow heads, and straight, elbow or curved routes. +- Snaps to a grid, and supports zoom, pan, undo, redo, copy, paste and duplicate. +- Keeps several drawings per session. Switch, rename, duplicate or delete them from the drawing menu. +- Exports PNG or SVG files, or copies the drawing as an image. +- **Ask Copilot** sends the drawing to the chat with your question, both as an image and as a text outline with element ids. +- Matches the Copilot app's light or dark theme, or stays light or dark if you prefer. + +## Install + +Ask Copilot to install the extension: + +```text +Install this extension: https://github.com/github/awesome-copilot/tree/main/extensions/draw +``` + +Reload extensions, then ask Copilot to open the `draw` canvas. + +## Drawing with Copilot + +Ask in the chat, for example "Draw how our sign-in flow works" or "Add a cache between the API and the database". Copilot's changes show up live, and you can undo them in the canvas. The canvas provides these actions: + +| Action | What it does | +| --- | --- | +| `get_drawing` | Reads the drawing as an outline with element ids, plus what the user has selected. A big drawing comes back in parts | +| `set_diagram` | Replaces the drawing with nodes and edges, laid out automatically | +| `add_elements` | Adds nodes, edges and text next to what is already there | +| `update_elements` | Changes labels, shapes, colors, sizes, positions and arrow ends | +| `delete_elements` | Deletes elements, and any arrows attached to deleted shapes | +| `layout` | Re-arranges the shapes into neat layers that follow the arrows | +| `clear` | Removes everything | +| `export_image` | Saves a PNG or SVG and returns its path, so Copilot can look at the result | +| `list_drawings` | Lists the drawings in this session | +| `open_drawing` | Shows another drawing, or creates a new one | +| `select_elements` | Selects elements to point them out to the user | +| `set_theme` | Switches between matching the app, light and dark | + +## Keyboard shortcuts + +Press `?` in the canvas to see all of them. `Tab` moves focus between the canvas and its toolbars. On macOS, use `Cmd` instead of `Ctrl`. + +| Keys | Action | +| --- | --- | +| `V`, `H` | Select, pan | +| `R`, `O`, `D`, `C` | Rectangle, ellipse, diamond, database | +| `A`, `T`, `P` | Arrow, text, pen | +| `N`, `Shift+N` | Select the next or previous element | +| `Enter` or double-click | Edit a label | +| `Enter` with a shape or text tool | Add one in the middle of the view | +| `Enter` with the arrow tool | Start an arrow at the selected shape, then press it again to end the arrow at the next shape you select | +| `Tab` while typing a shape's label | Add the next step (`Shift+Tab` adds a sibling) | +| `Ctrl+Arrow` | Add a shape in that direction | +| Arrow keys | Move the selection (`Alt`: 1 px, `Shift`: 50 px), or pan when nothing is selected | +| `Ctrl+Shift+Arrow` | Resize the selected shape | +| `Ctrl+Z`, `Ctrl+Shift+Z` | Undo, redo | +| `Shift+1` | Fit the drawing on screen | +| `Esc` | Cancel, or go back to the canvas from a toolbar | + +## Where your data goes + +- Drawings are JSON files in the current session's `files/drawings` folder, and exports are saved in `files/drawings/exports`. Each session has its own drawings. +- If a drawing can't be written to disk, the canvas says so, keeps your changes in memory and keeps trying to save them. +- The theme choice is saved in `~/.copilot/draw/settings.json` (under `COPILOT_HOME` when it is set) and applies to every session. + +## Security + +The canvas is served by a local server that listens on `127.0.0.1` on a random port. It checks the `Host` header, requires a random per-server token on every API request, and sends a content security policy that only allows scripts and connections from itself. The extension has no dependencies besides the Copilot SDK and makes no requests to the internet. The only process it starts is your file manager (Explorer, Finder or `xdg-open`), when you choose **Show in folder** after an export. + +## Tests + +The tests use Node's built-in test runner, so there is nothing to install. From this folder, run: + +```sh +node --test +``` diff --git a/extensions/draw/actions.mjs b/extensions/draw/actions.mjs new file mode 100644 index 000000000..cebeabb39 --- /dev/null +++ b/extensions/draw/actions.mjs @@ -0,0 +1,447 @@ +// Agent-callable actions for the Draw canvas. Each action works on the drawing +// that the calling canvas instance is showing. +import { + SHAPE_TYPES, COLORS, FILLS, HEADS, ROUTES, SIZE_KEYS, MAX_SIDE, isShape, + normalizeElement, removeWithArrows, staticPaint, LIGHT_PALETTE, DARK_PALETTE, +} from "./lib/model.mjs"; +import { DIRECTIONS, buildFromSpec, relayout, describe, outlinePage } from "./lib/layout.mjs"; +import { approxMeasure, neededHeight } from "./lib/geometry.mjs"; +import { renderStandaloneSVG } from "./lib/render.mjs"; +import { THEMES } from "./settings.mjs"; +import { StoreError } from "./store.mjs"; + +const OUTLINE_LIMIT = 80; +// About how many characters get_drawing sends at a time, so a big drawing comes in parts. +const PART_BUDGET = 30000; +// A selection bigger than this is counted instead of listed, and selectedOnly reads it. +const SELECTION_BUDGET = 10000; + +const str = (description) => ({ type: "string", description }); +const num = (description) => ({ type: "number", description }); +const color = { type: "string", enum: COLORS, description: "Color. gray is the default." }; +const fill = { type: "string", enum: FILLS, description: "none (outline only), soft (light tint, the default) or solid (strong color)." }; +const size = { type: "string", enum: SIZE_KEYS, description: "Text size: s, m, l or xl. Shapes default to m, arrows to s and text to l." }; +const dashed = { type: "boolean", description: "Draw a dashed line." }; +const head = { type: "string", enum: HEADS, description: "Arrowheads: end (the default), start, both or none." }; +const route = { type: "string", enum: ROUTES, description: "Arrow path: straight (the default), elbow (right angles) or curve." }; +const direction = { type: "string", enum: DIRECTIONS, description: "Which way the diagram flows. right is the default." }; +const shape = { + type: "string", + enum: SHAPE_TYPES, + description: "rect (the default: a step or component), ellipse (start or end), diamond (a decision) or cylinder (a database or storage).", +}; + +const nodeSchema = { + type: "object", + additionalProperties: false, + properties: { + id: str('Short id that edges can refer to, like "api". Defaults to one made from the label.'), + label: str("Text inside the shape. Use \\n for a line break."), + shape, color, fill, dashed, size, + x: num("Left edge in pixels. Give x and y together, or leave both out for automatic layout."), + y: num("Top edge in pixels. y grows downward."), + w: num("Width in pixels. Defaults to fit the label."), + h: num("Height in pixels. Defaults to fit the label."), + }, +}; +const edgeSchema = { + type: "object", + additionalProperties: false, + required: ["from", "to"], + properties: { + from: str("Id or exact label of the shape the arrow starts at."), + to: str("Id or exact label of the shape the arrow points to."), + label: str("Optional text on the arrow."), + id: str("Optional arrow id."), + color, dashed, head, route, + }, +}; +const textSchema = { + type: "object", + additionalProperties: false, + required: ["text"], + properties: { + text: str("The text. Use \\n for a line break."), + id: str("Optional id."), + color, size, + x: num("Left edge in pixels. Leave out x and y to put it above the diagram, like a title."), + y: num("Top edge in pixels."), + }, +}; +const updateSchema = { + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: str("Id of the element to change."), + label: str("New label or text. An empty string clears it."), + shape, color, fill, dashed, size, head, route, + x: num("New left edge (shapes and text)."), + y: num("New top edge (shapes and text)."), + w: num("New width (shapes)."), + h: num("New height (shapes)."), + from: str("Arrows only: id of the shape the arrow should start at."), + to: str("Arrows only: id of the shape the arrow should point to."), + }, +}; +const specProps = { + nodes: { type: "array", items: nodeSchema, maxItems: 500, description: "Shapes with labels." }, + edges: { type: "array", items: edgeSchema, maxItems: 1000, description: "Arrows between shapes. They stay attached when shapes move." }, + texts: { type: "array", items: textSchema, maxItems: 200, description: "Free text, like a title or a note." }, + direction, +}; + +const APPLIES = { + shape: ["label", "shape", "color", "fill", "dashed", "size", "x", "y", "w", "h"], + text: ["label", "color", "size", "x", "y"], + arrow: ["label", "color", "dashed", "size", "head", "route", "from", "to"], + pen: ["color"], +}; + +function patchElement(el, u, byId) { + const kind = isShape(el) ? "shape" : el.type; + const allowed = APPLIES[kind] || []; + const ignored = Object.keys(u).filter((k) => k !== "id" && !allowed.includes(k)); + const p = { ...el }; + const set = (key, prop = key) => { + if (u[key] !== undefined && allowed.includes(key)) p[prop] = u[key]; + }; + set("label", "text"); + set("color"); + set("size"); + set("fill"); + set("dashed", "dash"); + set("head"); + set("route"); + set("x"); + set("y"); + set("w"); + set("h"); + set("shape", "type"); + for (const end of ["from", "to"]) { + if (u[end] === undefined || kind !== "arrow") continue; + if (!isShape(byId.get(u[end]))) return { error: `Arrow "${el.id}": "${u[end]}" is not the id of a shape.` }; + p[end] = u[end]; + } + if (kind === "arrow" && p.from && p.from === p.to) return { error: `Arrow "${el.id}" cannot start and end at the same shape.` }; + let next = normalizeElement(p); + // Like the editor, grow a shape's height to fit a longer label, keeping its center. The size + // limit applies first, so the shape is centered on the height it really gets. + if (kind === "shape" && u.h === undefined && ["label", "size", "shape", "w"].some((k) => u[k] !== undefined)) { + const need = Math.min(MAX_SIDE, neededHeight(next, approxMeasure)); + if (need > next.h) { + next = normalizeElement({ ...next, h: need, y: u.y === undefined ? next.y - (need - next.h) / 2 : next.y }); + } + } + return { el: next, ignored }; +} + +// Wraps handlers with schema boilerplate and the lookup of the instance's drawing. +export function makeActions({ runtime, CanvasError }) { + const fail = (code, message) => { + throw new CanvasError(code, message); + }; + const specErrors = (errors) => fail("invalid_diagram", `Nothing was changed. Fix these and try again:\n- ${errors.join("\n- ")}`); + // The user can open another drawing at any time, so actions on the shown drawing can name the one + // they mean and then refuse to touch a different one. + const pin = { + type: "string", + description: "Optional. The id of the drawing you mean. If the canvas shows a different drawing now, nothing is done and the error says which one it shows.", + }; + + const action = (name, description, properties, required, handler, { anyDrawing = false } = {}) => ({ + name, + description, + inputSchema: { type: "object", properties: anyDrawing ? properties : { ...properties, drawingId: pin }, required, additionalProperties: false }, + handler: async (ctx) => { + const { store, server } = await runtime(ctx); + const input = ctx.input || {}; + const doc = server.ensureDrawing(ctx.instanceId); + if (!anyDrawing && input.drawingId && input.drawingId !== doc.id) { + fail( + "drawing_changed", + `Nothing was done: this canvas shows "${doc.name}" (id ${doc.id}) now, not drawing ${input.drawingId}. The user may have opened it on purpose, so ask before calling open_drawing to go back.`, + ); + } + let result; + try { + result = await handler({ store, server, doc, instanceId: ctx.instanceId }, input); + } catch (err) { + if (err instanceof StoreError) fail(err.code, err.message); + throw err; + } + // Write the change now, so a disk problem is reported by this action instead of never. + const saveError = result?.drawing?.id ? await store.persist(result.drawing.id) : null; + if (saveError) { + result.warning = `This drawing could not be saved to disk (${saveError}). The changes are kept in memory and saving is being retried, so tell the user.`; + } + return result; + }, + }); + + const summary = (doc, message, changedIds) => { + const result = { ok: true, message, drawing: { id: doc.id, name: doc.name, rev: doc.rev } }; + if (changedIds) result.changedIds = changedIds; + if (doc.elements.length <= OUTLINE_LIMIT) result.outline = describe(doc); + else result.note = `The drawing has ${doc.elements.length} elements. Call get_drawing to see all of them.`; + return result; + }; + + return [ + action( + "get_drawing", + "Read the drawing shown in this canvas: an outline of its elements with their ids, labels, positions, sizes and styles (a style that is not listed is the default), plus the ids the user has selected right now. Coordinates are pixels, x grows right and y grows down. A big drawing comes in parts: when the result has nextStart, call again with start set to it to read the next part.", + { + start: { type: "integer", minimum: 0, description: "Where to continue a long outline: the nextStart of the previous result." }, + ids: { type: "array", items: { type: "string" }, maxItems: 5000, description: "Only list these elements." }, + selectedOnly: { type: "boolean", description: "Only list the elements the user has selected." }, + includeElements: { type: "boolean", description: "Also return the raw element JSON of the listed elements, with long labels in full. Usually the outline is enough." }, + }, + [], + ({ store, server, doc, instanceId }, input) => { + const selected = server.selection(instanceId, doc.id); + let only = Array.isArray(input.ids) ? new Set(input.ids) : null; + if (input.selectedOnly) only = new Set(selected.filter((id) => !only || only.has(id))); + const part = outlinePage(doc, { + start: Number.isInteger(input.start) && input.start > 0 ? input.start : 0, + budget: PART_BUDGET, + only, + cost: input.includeElements ? (el) => JSON.stringify(el).length : null, + }); + const result = { + drawing: { id: doc.id, name: doc.name, rev: doc.rev, updatedAt: doc.updatedAt, file: store.filePath(doc.id) }, + outline: part.text, + }; + if (part.next !== null) { + result.nextStart = part.next; + result.note = `This part lists ${part.shown.length} of ${part.total} elements. Call get_drawing with start ${part.next}${only ? " and the same ids or selectedOnly" : ""} to read the next part.`; + } + if (JSON.stringify(selected).length <= SELECTION_BUDGET) { + result.selectedIds = selected; + } else { + result.selectedCount = selected.length; + result.selectionNote = `The user has selected ${selected.length} elements, too many to list here. Call get_drawing with selectedOnly to read them.`; + } + result.canvasOpen = server.hasClient(instanceId); + if (Array.isArray(input.ids)) { + const known = new Set(doc.elements.map((e) => e.id)); + const missing = input.ids.filter((id) => !known.has(id)); + if (missing.length) result.missingIds = missing; + } + if (input.includeElements) result.elements = part.shown; + return result; + }, + ), + + action( + "set_diagram", + "Replace the whole drawing with a new diagram. List nodes (labeled shapes) and edges (arrows between node ids or labels), and the layout is done for you unless you give x and y. Best for drawing something from scratch. The user can undo it in the canvas.", + { ...specProps, name: str("Optional new name for the drawing.") }, + ["nodes"], + ({ store, doc }, input) => { + const spec = { nodes: input.nodes || [], edges: input.edges || [], texts: input.texts || [] }; + const { elements, errors } = buildFromSpec(spec, { existing: [], direction: input.direction || "right", measure: approxMeasure }); + if (errors.length) specErrors(errors); + if (input.name && input.name.trim()) store.rename(doc.id, input.name); + const next = store.replace(doc.id, elements, "agent"); + return summary(next, `Drew ${elements.length} elements.`); + }, + ), + + action( + "add_elements", + "Add nodes, edges and text to the drawing without touching what is already there. Edges can connect to existing shapes by id or by exact label, and new nodes are placed next to the shapes they connect to.", + specProps, + [], + ({ store, doc }, input) => { + const spec = { nodes: input.nodes || [], edges: input.edges || [], texts: input.texts || [] }; + if (!spec.nodes.length && !spec.edges.length && !spec.texts.length) fail("invalid_input", "Give at least one node, edge or text."); + const { elements, errors } = buildFromSpec(spec, { existing: doc.elements, direction: input.direction || "right", measure: approxMeasure }); + if (errors.length) specErrors(errors); + const next = store.applyOps(doc.id, { upserts: elements }, "agent"); + return summary(next, `Added ${elements.length} elements.`, elements.map((e) => e.id)); + }, + ), + + action( + "update_elements", + "Change existing elements by id: label, shape, color, fill, dashed, text size, position (x, y), box size (w, h), or for arrows their ends (from, to), head and route. Shapes grow taller to fit a longer label.", + { updates: { type: "array", items: updateSchema, minItems: 1, maxItems: 1000 } }, + ["updates"], + ({ store, doc }, input) => { + const byId = new Map(doc.elements.map((e) => [e.id, e])); + const changed = new Map(); + const errors = []; + const warnings = []; + for (const u of input.updates) { + const current = changed.get(u.id) || byId.get(u.id); + if (!current) { + errors.push(`No element has the id "${u.id}".`); + continue; + } + const res = patchElement(current, u, byId); + if (res.error) { + errors.push(res.error); + continue; + } + if (res.ignored.length) { + const kind = isShape(current) ? "shape" : current.type; + warnings.push(`"${u.id}" is ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, so ${res.ignored.join(", ")} did not apply.`); + } + changed.set(u.id, res.el); + } + if (errors.length) fail("invalid_update", `Nothing was changed:\n- ${errors.join("\n- ")}`); + const next = store.applyOps(doc.id, { upserts: [...changed.values()] }, "agent"); + const result = summary(next, `Updated ${changed.size} elements.`, [...changed.keys()]); + if (warnings.length) result.warnings = warnings; + return result; + }, + ), + + action( + "delete_elements", + "Delete elements by id. Arrows attached to a deleted shape are deleted too.", + { ids: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5000 } }, + ["ids"], + ({ store, doc }, input) => { + const known = new Set(doc.elements.map((e) => e.id)); + const missing = input.ids.filter((id) => !known.has(id)); + const { removed } = removeWithArrows(doc.elements, input.ids.filter((id) => known.has(id))); + if (!removed.length) fail("not_found", `No elements have these ids: ${missing.join(", ")}. Call get_drawing to see the ids.`); + const next = store.applyOps(doc.id, { deletes: removed }, "agent"); + const result = summary(next, `Deleted ${removed.length} elements.`, removed); + if (missing.length) result.missingIds = missing; + return result; + }, + ), + + action( + "layout", + "Tidy up the drawing by re-arranging all shapes into neat layers that follow the arrows. Text and pen strokes stay where they are.", + { direction }, + [], + ({ store, doc }, input) => { + if (!doc.elements.some(isShape)) fail("empty", "There are no shapes to arrange."); + const { elements: arranged, error } = relayout(doc.elements, { direction: input.direction || "right", measure: approxMeasure }); + if (error) fail("layout_too_big", `Nothing was changed. ${error}`); + const next = store.replace(doc.id, arranged, "agent"); + return summary(next, "Re-arranged the shapes."); + }, + ), + + action( + "clear", + "Remove everything from the drawing. The user can undo this in the canvas.", + {}, + [], + ({ store, doc }) => summary(store.replace(doc.id, [], "agent"), "Cleared the drawing."), + ), + + action( + "export_image", + "Save the drawing as a PNG or SVG file and return the file path. The image uses the canvas's current theme (light or dark). To look at the drawing yourself, export a PNG and open the path with the view tool.", + { format: { type: "string", enum: ["png", "svg"], description: "png (the default) or svg." } }, + [], + async ({ store, server, doc, instanceId }, input) => { + const format = input.format || "png"; + if (!doc.elements.length) fail("empty", "The drawing is empty, so there is nothing to export."); + let data = null; + const pending = server.requestExport(instanceId, doc.id, format); + if (pending) { + try { + const res = await pending; + data = format === "png" ? Buffer.from(res.data, "base64") : res.data; + } catch (err) { + if (format === "png") fail("export_failed", err.message); + } + } + if (!data || !data.length) { + if (format === "png") { + fail("canvas_not_open", 'Saving a PNG needs the Draw canvas to be open on screen. Open it with open_canvas and try again, or use format "svg".'); + } + // Without the page we cannot know the app's theme, so only an explicit dark choice gives dark. + const palette = (await server.readSettings()).theme === "dark" ? DARK_PALETTE : LIGHT_PALETTE; + data = renderStandaloneSVG(doc.elements, { measure: approxMeasure, paint: staticPaint(palette), background: palette.bg }); + } + const file = await store.writeExport(doc.id, format, data); + return { ok: true, format, path: file }; + }, + ), + + action( + "list_drawings", + "List the drawings saved in this session, newest first.", + {}, + [], + ({ store, doc }) => ({ currentId: doc.id, drawings: store.list() }), + { anyDrawing: true }, + ), + + action( + "open_drawing", + "Show another drawing in this canvas, found by name or id. Creates a new empty drawing with that name if none matches. If a name could mean more than one drawing, nothing opens and the error lists their ids; pass one of those instead, since an id always picks one. If the canvas has changes it cannot save yet, it stays on its drawing and the error says so.", + { + name: str("Name or id of the drawing."), + create: { type: "boolean", description: "Create the drawing when none matches. Defaults to true." }, + }, + ["name"], + async ({ store, server, doc: current, instanceId }, input) => { + let doc = store.find(input.name); + let created = false; + if (!doc) { + if (input.create === false) fail("not_found", `No drawing is named "${input.name}". Call list_drawings to see them.`); + doc = store.create(input.name); + created = true; + } + const outcome = await server.openDrawing(instanceId, doc); + if (outcome === "refused") { + fail( + "unsaved_changes", + `The canvas stayed on "${current.name}" (id ${current.id}): it has changes that could not be saved yet, and opening "${doc.name}" would lose them. The user was offered a button to discard them and open it.${created ? ` "${doc.name}" was created, so opening it again finds it.` : ""}`, + ); + } + let message = created ? `Created and opened "${doc.name}".` : `Opened "${doc.name}".`; + if (outcome === "unconfirmed") { + message += ` The canvas has not said yet that it shows it, so pass drawingId "${doc.id}" to the next actions: then they change nothing if it stays on another drawing.`; + } + const sameName = store.list().filter((d) => d.id !== doc.id && d.name.toLowerCase() === doc.name.toLowerCase()); + if (sameName.length) message += ` Other drawings have this name too: ${sameName.map((d) => d.id).join(", ")}.`; + return { ...summary(doc, message), created }; + }, + { anyDrawing: true }, + ), + + action( + "select_elements", + "Select elements in the canvas to point them out to the user. Pass an empty list to clear the selection.", + { ids: { type: "array", items: { type: "string" }, maxItems: 5000 } }, + ["ids"], + ({ server, doc, instanceId }, input) => { + const known = new Set(doc.elements.map((e) => e.id)); + const ids = input.ids.filter((id) => known.has(id)); + server.select(instanceId, doc.id, ids); + const result = { ok: true, selectedIds: ids, canvasOpen: server.hasClient(instanceId) }; + const missing = input.ids.filter((id) => !known.has(id)); + if (missing.length) result.missingIds = missing; + return result; + }, + ), + + action( + "set_theme", + 'Switch the canvas between light and dark. "app" follows the Copilot app\'s theme, which is the default. Only do this when the user asks. The choice is remembered for every session.', + { theme: { type: "string", enum: THEMES, description: "app (match the Copilot app), light or dark." } }, + ["theme"], + async ({ server, instanceId }, input) => { + let settings; + try { + settings = await server.updateSettings({ theme: input.theme }); + } catch (err) { + fail("save_failed", `Could not save the theme: ${err.message}`); + } + return { ok: true, theme: settings.theme, canvasOpen: server.hasClient(instanceId) }; + }, + { anyDrawing: true }, + ), + ]; +} diff --git a/extensions/draw/actions.test.mjs b/extensions/draw/actions.test.mjs new file mode 100644 index 000000000..d390c3f21 --- /dev/null +++ b/extensions/draw/actions.test.mjs @@ -0,0 +1,246 @@ +// Tests for the agent actions, run against a real store on a temp folder. +// Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { makeActions } from "./actions.mjs"; +import { MAX_PLACEHOLDERS } from "./lib/layout.mjs"; +import { DrawingStore } from "./store.mjs"; + +class CanvasError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +// The actions for one panel, with a stand-in for the server that has no canvas open. +async function setup(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), "draw-actions-test-")); + const store = new DrawingStore(dir); + await store.ready; + const doc = store.create("Plan"); + await store.flush(); + const server = { ensureDrawing: () => store.get(doc.id), hasClient: () => false, selection: () => [] }; + const actions = makeActions({ runtime: async () => ({ store, server }), CanvasError }); + const run = (name, input) => actions.find((a) => a.name === name).handler({ instanceId: "panel", input }); + t.after(async () => { + await store.flush().catch(() => {}); + await rm(dir, { recursive: true, force: true, maxRetries: 3 }); + }); + return { store, doc, run, server }; +} + +test("a change the agent makes is on disk when the action returns", async (t) => { + const { store, doc, run } = await setup(t); + const result = await run("add_elements", { nodes: [{ id: "api", label: "API" }] }); + assert.equal(result.ok, true); + assert.equal(result.warning, undefined); + const saved = JSON.parse(await readFile(store.filePath(doc.id), "utf8")); + assert.deepEqual(saved.elements.map((e) => e.id), ["api"]); +}); + +test("a change that cannot be saved is reported by the action that made it", async (t) => { + const { store, doc, run } = await setup(t); + // A folder where the drawing's file should be makes the write fail. + const file = store.filePath(doc.id); + await rm(file); + await mkdir(file); + await writeFile(path.join(file, "keep"), ""); + + const result = await run("add_elements", { nodes: [{ id: "api", label: "API" }] }); + assert.equal(result.ok, true); + assert.match(result.warning, /could not be saved to disk/); + assert.ok(result.warning.includes(store.saveError(doc.id))); + await rm(file, { recursive: true }); +}); + +test("a shape that grows to fit a longer label keeps its center, even at the size limit", async (t) => { + const { store, doc, run } = await setup(t); + store.replace(doc.id, [{ id: "a", type: "rect", x: 0, y: 1000, w: 20, h: 100 }], "test"); + await run("update_elements", { updates: [{ id: "a", label: "ab ".repeat(1300), size: "xl" }] }); + const [a] = store.get(doc.id).elements; + assert.equal(a.h, 5000); + assert.equal(a.y + a.h / 2, 1050); +}); + +test("get_drawing reads the biggest drawing in parts of limited size", async (t) => { + const { store, doc, run, server } = await setup(t); + // The most a drawing can hold: 5,000 shapes with 4,000 character labels. + const label = "x".repeat(4000); + const all = Array.from({ length: 5000 }, (_, i) => `r${i}`); + store.replace(doc.id, all.map((id, i) => ({ id, type: "rect", x: (i % 100) * 200, y: Math.floor(i / 100) * 100, text: label })), "test"); + const listed = (result) => [...result.outline.matchAll(/^- (r\d+) /gm)].map((m) => m[1]); + + const seen = []; + let start = 0; + for (;;) { + const result = await run("get_drawing", { start }); + const size = JSON.stringify(result).length; + assert.ok(size < 40000, `a part has ${size} characters`); + seen.push(...listed(result)); + if (result.nextStart === undefined) break; + assert.equal(result.nextStart, seen.length); + start = result.nextStart; + } + assert.deepEqual(seen, all); + + // Chosen elements come with their JSON, where labels are not cut. + const chosen = await run("get_drawing", { ids: ["r7", "gone"], includeElements: true }); + assert.deepEqual(chosen.elements.map((e) => [e.id, e.text.length]), [["r7", 4000]]); + assert.deepEqual(chosen.missingIds, ["gone"]); + assert.equal(chosen.nextStart, undefined); + + // A selection too big to list is counted, and selectedOnly reads it in parts too. + server.selection = () => all; + const counted = await run("get_drawing", {}); + assert.equal(counted.selectedIds, undefined); + assert.equal(counted.selectedCount, 5000); + assert.ok(JSON.stringify(counted).length < 40000); + server.selection = () => ["r3", "r1"]; + const selected = await run("get_drawing", { selectedOnly: true }); + assert.deepEqual(selected.selectedIds, ["r3", "r1"]); + assert.deepEqual(listed(selected), ["r1", "r3"]); +}); + +test("an action that names another drawing than the one shown changes nothing", async (t) => { + const { store, doc, run } = await setup(t); + const nodes = [{ id: "api", label: "API" }]; + await assert.rejects(run("add_elements", { drawingId: "old-plan", nodes }), (err) => { + assert.equal(err.code, "drawing_changed"); + assert.ok(err.message.includes(`id ${doc.id}`)); + return true; + }); + assert.equal(store.get(doc.id).elements.length, 0); + assert.equal((await run("add_elements", { drawingId: doc.id, nodes })).ok, true); + assert.equal(store.get(doc.id).elements.length, 1); +}); + +test("a name more than one drawing has opens nothing and lists their ids", async (t) => { + const { store, run, server } = await setup(t); + const shown = []; + server.openDrawing = async (instanceId, d) => { + shown.push(d.id); + return "shown"; + }; + assert.equal(store.create("Plan").id, "plan-2"); + for (const name of ["Plan", "PLAN", "plan!"]) { + await assert.rejects(run("open_drawing", { name }), (err) => { + assert.ok(err instanceof CanvasError); + assert.equal(err.code, "ambiguous_name"); + assert.match(err.message, /plan \("Plan"\), plan-2 \("Plan"\)/); + return true; + }); + } + assert.deepEqual(shown, []); + assert.equal(store.list().length, 2); + + // An id always picks one, and the result names the others. + const second = await run("open_drawing", { name: "plan-2" }); + assert.equal(second.drawing.id, "plan-2"); + assert.match(second.message, /Other drawings have this name too: plan\./); + assert.equal((await run("open_drawing", { name: "plan" })).drawing.id, "plan"); + assert.deepEqual(shown, ["plan-2", "plan"]); +}); + +test("open_drawing says when the canvas could not take the drawing, or has not said it did", async (t) => { + const { store, run, server } = await setup(t); + let outcome = "refused"; + server.openDrawing = async () => outcome; + await assert.rejects(run("open_drawing", { name: "Other" }), (err) => { + assert.equal(err.code, "unsaved_changes"); + assert.match(err.message, /^The canvas stayed on "Plan" \(id plan\): it has changes that could not be saved yet, and opening "Other" would lose them\./); + assert.match(err.message, / "Other" was created, so opening it again finds it\.$/); + return true; + }); + + outcome = "unconfirmed"; + const result = await run("open_drawing", { name: "Other" }); + assert.equal(result.created, false); + assert.match(result.message, /^Opened "Other"\. The canvas has not said yet that it shows it, so pass drawingId "other" to the next actions/); + assert.equal(store.list().length, 2); +}); + +test("every action on the shown drawing can name the drawing it means", () => { + const actions = makeActions({ runtime: async () => ({}), CanvasError }); + const unpinned = actions.filter((a) => !a.inputSchema.properties.drawingId).map((a) => a.name); + assert.deepEqual(unpinned.sort(), ["list_drawings", "open_drawing", "set_theme"]); +}); + +test("a layout that would not fit on the canvas changes nothing and says why", async (t) => { + const { store, doc, run } = await setup(t); + // Long arrow labels spread the layers apart, so this chain would reach past x = 10,000,000. + const label = "x".repeat(4000); + const nodes = Array.from({ length: 500 }, (_, i) => ({ id: `n${i}`, label: `Step ${i}` })); + const edges = nodes.slice(1).map((n, i) => ({ from: `n${i}`, to: n.id, label })); + const tooBig = /The layout does not fit: "n\d+" would be at \d+,-?\d+, but positions only go from -1,000,000 to 1,000,000\./; + for (const name of ["set_diagram", "add_elements"]) { + await assert.rejects(run(name, { nodes, edges }), (err) => { + assert.equal(err.code, "invalid_diagram"); + assert.match(err.message, tooBig); + return true; + }); + } + assert.deepEqual(store.get(doc.id).elements, []); + + // The same diagram, drawn by hand, cannot be laid out either. + store.replace(doc.id, [ + ...nodes.map((n, i) => ({ id: n.id, type: "rect", x: (i % 25) * 200, y: Math.floor(i / 25) * 100, text: n.label })), + ...edges.map((e, i) => ({ id: `e${i}`, type: "arrow", from: e.from, to: e.to, text: e.label })), + ], "test"); + const before = store.get(doc.id); + await assert.rejects(run("layout", {}), (err) => { + assert.equal(err.code, "layout_too_big"); + assert.match(err.message, /^Nothing was changed\./); + assert.match(err.message, tooBig); + return true; + }); + assert.equal(store.get(doc.id).rev, before.rev); + + // With short labels it fits, and no two shapes end up in the same spot. + store.replace(doc.id, before.elements.map((e) => (e.type === "arrow" ? { ...e, text: "next" } : e)), "test"); + assert.equal((await run("layout", {})).ok, true); + const spots = new Set(store.get(doc.id).elements.filter((e) => e.type === "rect").map((e) => `${e.x},${e.y}`)); + assert.equal(spots.size, 500); +}); + +test("a layout with too many long arrows changes nothing and says why", async (t) => { + const { store, doc, run } = await setup(t); + // A chain of 400 steps, with 300 arrows that jump from near its start to near its end. Each + // one passes hundreds of layers, and needs a placeholder on every one of them. + const nodes = Array.from({ length: 400 }, (_, i) => ({ id: `n${i}`, label: `Step ${i}` })); + const edges = nodes.slice(1).map((n, i) => ({ from: `n${i}`, to: n.id })); + let needed = 0; + for (let k = 0; k < 300; k++) { + const from = k % 20; + const to = 399 - Math.floor(k / 20); + edges.push({ from: `n${from}`, to: `n${to}` }); + needed += to - from - 1; + } + assert.ok(needed > MAX_PLACEHOLDERS); + const count = (n) => n.toLocaleString("en-US"); + const tooBig = new RegExp(`The layout is too big to work out: .* this diagram needs ${count(needed)}, more than the ${count(MAX_PLACEHOLDERS)} automatic layout handles\\.`); + for (const name of ["set_diagram", "add_elements"]) { + await assert.rejects(run(name, { nodes, edges }), (err) => { + assert.equal(err.code, "invalid_diagram"); + assert.match(err.message, tooBig); + return true; + }); + } + assert.deepEqual(store.get(doc.id).elements, []); + + store.replace(doc.id, [ + ...nodes.map((n, i) => ({ id: n.id, type: "rect", x: (i % 20) * 200, y: Math.floor(i / 20) * 100, text: n.label })), + ...edges.map((e, i) => ({ id: `e${i}`, type: "arrow", from: e.from, to: e.to })), + ], "test"); + const before = store.get(doc.id); + await assert.rejects(run("layout", {}), (err) => { + assert.equal(err.code, "layout_too_big"); + assert.match(err.message, /^Nothing was changed\. The layout is too big to work out:/); + assert.match(err.message, tooBig); + return true; + }); + assert.equal(store.get(doc.id).rev, before.rev); +}); diff --git a/extensions/draw/assets/preview.png b/extensions/draw/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..96374d4ab3eaec2599a845a5c47b34656bd1cb0f GIT binary patch literal 50232 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 literal 0 HcmV?d00001 diff --git a/extensions/draw/extension.mjs b/extensions/draw/extension.mjs new file mode 100644 index 000000000..2ce591f4e --- /dev/null +++ b/extensions/draw/extension.mjs @@ -0,0 +1,103 @@ +// Draw: a canvas for quick diagrams. Shapes, arrows that stay attached, text and pen. +// Drawings are JSON files in this session's workspace (files/drawings). One local +// HTTP server serves every open Draw panel in the session. +// +// Note: stdout is the JSON-RPC channel to the host, so never use console.log here. + +import os from "node:os"; +import path from "node:path"; +import { joinSession, createCanvas, CanvasError } from "@github/copilot-sdk/extension"; +import { DrawingStore, StoreError } from "./store.mjs"; +import { Settings, settingsFile } from "./settings.mjs"; +import { createDrawServer } from "./server.mjs"; +import { makeActions } from "./actions.mjs"; + +let session = null; +let runtimePromise = null; + +const log = (msg) => process.stderr.write(`[draw] ${msg}\n`); + +function workspaceDir(ctx) { + if (session && session.workspacePath) return session.workspacePath; + const home = process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"); + return path.join(home, "session-state", ctx.sessionId); +} + +// The store and server start on first use, so an unused canvas costs nothing. +function runtime(ctx) { + if (!runtimePromise) { + runtimePromise = (async () => { + const store = new DrawingStore(path.join(workspaceDir(ctx), "files", "drawings"), { log }); + await store.ready; + const settings = new Settings(settingsFile(), { log }); + const server = createDrawServer({ store, settings, getSession: () => session, log }); + await server.start(); + return { store, server }; + })(); + runtimePromise.catch((err) => { + log(`failed to start: ${err.stack || err.message}`); + runtimePromise = null; + }); + } + return runtimePromise; +} + +const canvas = createCanvas({ + id: "draw", + displayName: "Draw", + description: + "Draw simple diagrams: boxes, ellipses, diamonds and databases joined by arrows that stay attached, plus text and a freehand pen. The user draws by hand and you can read and change the same drawing with actions. Drawings are saved in this session.", + inputSchema: { + type: "object", + properties: { + drawing: { + type: "string", + description: + "Name or id of the drawing to show. It is created if nothing matches. If a name could mean more than one drawing, nothing opens and the error lists their ids; an id always picks one.", + }, + }, + additionalProperties: false, + }, + actions: makeActions({ runtime, CanvasError }), + open: async (ctx) => { + const { store, server } = await runtime(ctx); + const wanted = typeof ctx.input?.drawing === "string" ? ctx.input.drawing.trim() : ""; + if (wanted) { + const current = store.drawingForInstance(ctx.instanceId); + let doc; + try { + doc = store.find(wanted) || store.create(wanted); + } catch (err) { + if (err instanceof StoreError) throw new CanvasError(err.code, err.message); + throw err; + } + if (!current || current.id !== doc.id) server.showDrawing(ctx.instanceId, doc); + } else { + server.ensureDrawing(ctx.instanceId); + } + return { title: "Draw", url: server.urlFor(ctx.instanceId) }; + }, + // Panels come and go, but the server and the instance's drawing binding stay, + // so reopening the same instance shows the same drawing. + onClose: async () => { + if (!runtimePromise) return; + try { + await (await runtimePromise).store.flush(); + } catch (err) { + // The store keeps retrying failed saves, so closing a panel only needs to log it. + log(`could not save on close: ${err.message}`); + } + }, +}); + +// Host startup can replace canvas registrations during the initial burst. Let that settle first. +try { + await new Promise((resolve) => setTimeout(resolve, 900)); + session = await joinSession({ canvases: [canvas] }); +} catch (error) { + const msg = error && error.message ? String(error.message) : String(error); + if (/session not found/i.test(msg)) process.exit(0); + throw error; +} + +await session.log("Draw canvas ready.", { ephemeral: true }); diff --git a/extensions/draw/lib/geometry.mjs b/extensions/draw/lib/geometry.mjs new file mode 100644 index 000000000..1379c0dae --- /dev/null +++ b/extensions/draw/lib/geometry.mjs @@ -0,0 +1,631 @@ +// Geometry for drawings: text layout, shape outlines, arrow routing, bounds and hit testing. +import { FONT_SIZES, LABEL_WEIGHT, TEXT_WEIGHT, isShape, limitPoints } from "./model.mjs"; + +export const LABEL_PAD = 12; +export const ARROW_GAP = 3; +export const HEAD_LENGTH = 11; +export const HEAD_HALF_WIDTH = 5.5; +const HEAD_TRIM = 6; +const MIN_ROUTE_GAP = 16; + +export const fmt = (v) => String(Math.round(v * 100) / 100); +export const lineHeight = (fs) => Math.round(fs * 1.35); +export const baselineOffset = (fs, lh) => lh / 2 + 0.34 * fs; +export const fontSizeOf = (el) => FONT_SIZES[el.size] || FONT_SIZES.m; + +// Width units for ASCII characters, used where no real font measurement exists (Node side). +// Later groups overwrite earlier ones, so "I", "M" and "W" get the width of their own group +// rather than the width of the other capitals. +const ASCII_UNITS = new Float64Array(128).fill(0.54); +for (const [chars, units] of [ + ["ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$?_~+=<>*^", 0.64], + ["mwMW@%&", 0.86], + ["fjtIr()[]{}\"-/\\", 0.38], + ["il|!.,:;'`", 0.27], + [" ", 0.28], +]) { + for (const ch of chars) ASCII_UNITS[ch.charCodeAt(0)] = units; +} + +// Rough text width used where no real font measurement exists (Node side). +export function approxMeasure(str, fontSize, weight = 400) { + let units = 0; + for (const ch of String(str)) { + const code = ch.codePointAt(0); + units += code < 128 ? ASCII_UNITS[code] : code > 0x2e7f ? 1 : 0.54; + } + return units * fontSize * (weight >= 600 ? 1.06 : weight >= 500 ? 1.03 : 1); +} + +// Greedy word wrap. Words longer than the line are broken by character. Every check measures a +// whole line, so how many more words (or characters) fit is searched for rather than found by +// adding them one at a time. The search starts from the last line's count, since lines tend to +// hold about the same, so most lines take two measurements and none takes more than about +// 2 log n. Even a 4,000-character label with no spaces wraps quickly. +export function wrapText(text, maxWidth, fs, weight, measure = approxMeasure) { + const out = []; + const fits = (s) => measure(s, fs, weight) <= maxWidth; + let wordGuess = 1; + let charGuess = 1; + for (const para of String(text).split("\n")) { + const words = para.split(" "); + let line = ""; + let i = 0; + while (i < words.length) { + // The line with the next n words added. A word that starts an empty line has no space before it. + const withWords = (n) => { + let s = line; + for (let k = i; k < i + n; k++) s = s ? `${s} ${words[k]}` : words[k]; + return s; + }; + const n = longestFit(words.length - i, (m) => fits(withWords(m)), wordGuess); + wordGuess = Math.max(1, n); + if (n > 0) { + line = withWords(n); + i += n; + // n is the most that fit, so a word left over starts a new line. (When only empty words + // fit, the line is still empty, and the next word is cut to fit on it instead.) + if (i < words.length && line) { + out.push(line); + line = ""; + } + } else if (line) { + // The next word starts a new line. + out.push(line); + line = ""; + } else { + // It does not fit on a line of its own either, so it is cut into pieces that do (at + // least one character each). The last piece starts the next line. + const chars = Array.from(words[i++]); + const piece = (from, m) => chars.slice(from, from + m).join(""); + let start = 0; + for (;;) { + const len = Math.max(1, longestFit(chars.length - start, (m) => fits(piece(start, m)), charGuess)); + if (start + len >= chars.length) { + line = piece(start, len); + break; + } + out.push(piece(start, len)); + start += len; + charGuess = len; + } + } + } + out.push(line); + } + return out; +} + +// The largest n from 0 to max for which ok(n) holds, when ok holds up to some n and not after +// it (n = 0 counts as holding). It checks the guess first. Then, from the largest count known to +// fit (the guess, or 0 when the guess does not fit), it steps out by 1, 2, 4 ... until a step +// fails, and halves the gap that is left. So a good guess costs two checks, and a bad one never +// leads to checking much more than twice the answer, apart from the guess itself. +function longestFit(max, ok, guess = 1) { + if (max < 1) return 0; + const g = Math.min(Math.max(guess, 1), max); + let lo = 0; + let hi = max + 1; + if (ok(g)) lo = g; + else hi = g; + let step = 1; + while (lo + step < hi && ok(lo + step)) { + lo += step; + step *= 2; + } + hi = Math.min(hi, lo + step); + while (hi - lo > 1) { + const mid = (lo + hi) >> 1; + if (ok(mid)) lo = mid; + else hi = mid; + } + return lo; +} + +export function cylinderCap(s) { + return Math.min(s.h * 0.18, s.w * 0.25, 18); +} + +export function labelMaxWidth(s) { + if (s.type === "ellipse") return Math.max(20, s.w * 0.72 - 8); + if (s.type === "diamond") return Math.max(20, s.w * 0.56 - 8); + return Math.max(20, s.w - 2 * LABEL_PAD); +} + +export function labelCenterY(s) { + return s.type === "cylinder" ? s.y + s.h / 2 + cylinderCap(s) / 2 : s.y + s.h / 2; +} + +export function shapeLabelLayout(s, measure = approxMeasure) { + const fs = fontSizeOf(s); + const lh = lineHeight(fs); + const lines = s.text ? wrapText(s.text, labelMaxWidth(s), fs, LABEL_WEIGHT, measure) : []; + return { lines, fs, lh, cx: s.x + s.w / 2, top: labelCenterY(s) - (lines.length * lh) / 2 }; +} + +// Smallest height that fits the label at the shape's current width. +export function neededHeight(s, measure = approxMeasure) { + const { lines, lh } = shapeLabelLayout(s, measure); + const textH = Math.max(1, lines.length) * lh; + if (s.type === "ellipse") return textH / 0.69 + 8; + if (s.type === "diamond") return textH / 0.44 + 8; + if (s.type === "cylinder") return textH + 28 + cylinderCap(s) * 2; + return textH + 28; +} + +export function textLayout(t, measure = approxMeasure) { + const fs = fontSizeOf(t); + const lh = lineHeight(fs); + const lines = String(t.text || "").split("\n"); + const w = Math.max(fs * 0.5, ...lines.map((l) => measure(l, fs, TEXT_WEIGHT))); + return { lines, fs, lh, w, h: lines.length * lh }; +} + +// ---------- shapes ---------- + +export const shapeCenter = (s) => ({ x: s.x + s.w / 2, y: s.y + s.h / 2 }); + +// Where the ray from the shape center toward (tx, ty) leaves the outline, pushed out by gap. +export function boundaryPoint(s, tx, ty, gap = 0) { + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + const dx = tx - cx; + const dy = ty - cy; + const len = Math.hypot(dx, dy); + if (len < 1e-6) return { x: cx, y: cy }; + const ux = dx / len; + const uy = dy / len; + const hw = s.w / 2; + const hh = s.h / 2; + let t; + if (s.type === "ellipse") t = 1 / Math.sqrt((ux * ux) / (hw * hw) + (uy * uy) / (hh * hh)); + else if (s.type === "diamond") t = 1 / (Math.abs(ux) / hw + Math.abs(uy) / hh); + else t = Math.min(Math.abs(ux) > 1e-9 ? hw / Math.abs(ux) : Infinity, Math.abs(uy) > 1e-9 ? hh / Math.abs(uy) : Infinity); + return { x: cx + ux * (t + gap), y: cy + uy * (t + gap) }; +} + +// Inside test with a tolerance. For unfilled shapes, "border" limits hits to the outline band. +export function pointInShape(s, x, y, tol = 0, mode = "fill") { + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + const hw = s.w / 2; + const hh = s.h / 2; + const dx = Math.abs(x - cx); + const dy = Math.abs(y - cy); + let dist; + if (s.type === "ellipse") { + const r = Math.hypot(dx / hw, dy / hh); + dist = (r - 1) * Math.min(hw, hh); + } else if (s.type === "diamond") { + const r = dx / hw + dy / hh; + dist = ((r - 1) * hw * hh) / Math.hypot(hw, hh); + } else { + dist = Math.max(dx - hw, dy - hh); + } + if (mode === "border") return Math.abs(dist) <= tol + 3; + return dist <= tol; +} + +// ---------- arrows ---------- + +const SIDE_NORMAL = { left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, top: { x: 0, y: -1 }, bottom: { x: 0, y: 1 } }; + +function sideAnchor(s, side, gap) { + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + if (side === "left") return { x: s.x - gap, y: cy }; + if (side === "right") return { x: s.x + s.w + gap, y: cy }; + if (side === "top") return { x: cx, y: s.y - gap }; + return { x: cx, y: s.y + s.h + gap }; +} + +// Point on a side of the outline at cross-axis coordinate c (y for left/right, x for top/bottom). +function outlinePoint(s, side, c, gap) { + const cx = s.x + s.w / 2; + const cy = s.y + s.h / 2; + const hw = s.w / 2; + const hh = s.h / 2; + const horizontal = side === "left" || side === "right"; + const t = Math.min(1, horizontal ? Math.abs(c - cy) / hh : Math.abs(c - cx) / hw); + let half = horizontal ? hw : hh; + if (s.type === "ellipse") half *= Math.sqrt(1 - t * t); + else if (s.type === "diamond") half *= 1 - t; + else if (s.type === "cylinder" && !horizontal) { + const cap = cylinderCap(s); + half = hh - cap + cap * Math.sqrt(1 - t * t); + } + if (side === "left") return { x: cx - half - gap, y: c }; + if (side === "right") return { x: cx + half + gap, y: c }; + if (side === "top") return { x: c, y: cy - half - gap }; + return { x: c, y: cy + half + gap }; +} + +// Chooses which sides an elbow or curved connector leaves from and enters. +function pickSides(a, b) { + const gapX = Math.max(b.x - (a.x + a.w), a.x - (b.x + b.w)); + const gapY = Math.max(b.y - (a.y + a.h), a.y - (b.y + b.h)); + if (gapX < MIN_ROUTE_GAP && gapY < MIN_ROUTE_GAP) return null; + const acx = a.x + a.w / 2; + const acy = a.y + a.h / 2; + const bcx = b.x + b.w / 2; + const bcy = b.y + b.h / 2; + if (gapX >= gapY) return bcx >= acx ? ["right", "left"] : ["left", "right"]; + return bcy >= acy ? ["bottom", "top"] : ["top", "bottom"]; +} + +function dedupePoints(pts) { + const out = []; + for (const p of pts) { + const last = out[out.length - 1]; + if (last && Math.abs(last.x - p.x) < 0.01 && Math.abs(last.y - p.y) < 0.01) continue; + out.push(p); + } + // Drop middle points that sit on a straight line. + for (let i = out.length - 2; i >= 1; i--) { + const a = out[i - 1]; + const b = out[i]; + const c = out[i + 1]; + if (Math.abs((b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)) < 0.01) out.splice(i, 1); + } + return out; +} + +function roundedPath(pts, radius = 8) { + let d = `M${fmt(pts[0].x)} ${fmt(pts[0].y)}`; + for (let i = 1; i < pts.length - 1; i++) { + const p0 = pts[i - 1]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const l1 = Math.hypot(p1.x - p0.x, p1.y - p0.y); + const l2 = Math.hypot(p2.x - p1.x, p2.y - p1.y); + const r = Math.min(radius, l1 / 2, l2 / 2); + if (r < 0.5) { d += ` L${fmt(p1.x)} ${fmt(p1.y)}`; continue; } + const a = { x: p1.x + ((p0.x - p1.x) / l1) * r, y: p1.y + ((p0.y - p1.y) / l1) * r }; + const b = { x: p1.x + ((p2.x - p1.x) / l2) * r, y: p1.y + ((p2.y - p1.y) / l2) * r }; + d += ` L${fmt(a.x)} ${fmt(a.y)} Q${fmt(p1.x)} ${fmt(p1.y)} ${fmt(b.x)} ${fmt(b.y)}`; + } + const last = pts[pts.length - 1]; + return `${d} L${fmt(last.x)} ${fmt(last.y)}`; +} + +function unit(dx, dy) { + const len = Math.hypot(dx, dy) || 1; + return { x: dx / len, y: dy / len }; +} + +// Moves the first/last point of a polyline inward by `amount` (so strokes end under arrowheads). +function trimPolyline(pts, atStart, atEnd, amount) { + const out = pts.map((p) => ({ ...p })); + const trim = (i, j) => { + const len = Math.hypot(out[j].x - out[i].x, out[j].y - out[i].y); + const t = Math.min(amount, Math.max(0, len - 0.5)) / (len || 1); + out[i] = { x: out[i].x + (out[j].x - out[i].x) * t, y: out[i].y + (out[j].y - out[i].y) * t }; + }; + if (atStart && out.length > 1) trim(0, 1); + if (atEnd && out.length > 1) trim(out.length - 1, out.length - 2); + return out; +} + +function pointAtHalfLength(pts) { + let total = 0; + for (let i = 1; i < pts.length; i++) total += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + let remaining = total / 2; + for (let i = 1; i < pts.length; i++) { + const seg = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y); + if (seg >= remaining && seg > 0) { + const t = remaining / seg; + return { x: pts[i - 1].x + (pts[i].x - pts[i - 1].x) * t, y: pts[i - 1].y + (pts[i].y - pts[i - 1].y) * t }; + } + remaining -= seg; + } + return { ...pts[pts.length - 1] }; +} + +const bezier = (p0, c1, c2, p3, t) => { + const u = 1 - t; + return { + x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x, + y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y, + }; +}; + +function straightEnds(a, A, B) { + const p0 = A ? shapeCenter(A) : { x: a.x1, y: a.y1 }; + const q0 = B ? shapeCenter(B) : { x: a.x2, y: a.y2 }; + const p = A ? boundaryPoint(A, q0.x, q0.y, ARROW_GAP) : p0; + const q = B ? boundaryPoint(B, p0.x, p0.y, ARROW_GAP) : q0; + // Overlapping shapes: clipping flips the direction, so fall back to center-to-center. + if (A && B && (q.x - p.x) * (q0.x - p0.x) + (q.y - p.y) * (q0.y - p0.y) <= 0) return [p0, q0]; + return [p, q]; +} + +// Resolves the drawn path of an arrow. Returns null when an end is missing. +// Result: { d, points, start, end, startDir, endDir, mid } where *Dir point into the tips. +export function arrowGeometry(a, byId) { + const A = a.from ? byId.get(a.from) : null; + const B = a.to ? byId.get(a.to) : null; + if ((a.from && !A) || (a.to && !B)) return null; + const headStart = a.head === "start" || a.head === "both"; + const headEnd = a.head === "end" || a.head === "both"; + const boxA = A || { x: a.x1, y: a.y1, w: 0, h: 0 }; + const boxB = B || { x: a.x2, y: a.y2, w: 0, h: 0 }; + // A free end counts as a box with no size, so an arrow attached to nothing follows its route too. + const sides = a.route !== "straight" ? pickSides(boxA, boxB) : null; + + if (sides && a.route === "curve") { + const p = A ? sideAnchor(A, sides[0], ARROW_GAP) : { x: a.x1, y: a.y1 }; + const q = B ? sideAnchor(B, sides[1], ARROW_GAP) : { x: a.x2, y: a.y2 }; + const na = SIDE_NORMAL[sides[0]]; + const nb = SIDE_NORMAL[sides[1]]; + const k = Math.min(160, Math.max(24, Math.hypot(q.x - p.x, q.y - p.y) * 0.45)); + let c1 = { x: p.x + na.x * k, y: p.y + na.y * k }; + let c2 = { x: q.x + nb.x * k, y: q.y + nb.y * k }; + const startDir = { x: -na.x, y: -na.y }; + const endDir = { x: -nb.x, y: -nb.y }; + let ps = p; + let qs = q; + if (headStart) { ps = { x: p.x + na.x * HEAD_TRIM, y: p.y + na.y * HEAD_TRIM }; c1 = { x: c1.x + na.x * HEAD_TRIM, y: c1.y + na.y * HEAD_TRIM }; } + if (headEnd) { qs = { x: q.x + nb.x * HEAD_TRIM, y: q.y + nb.y * HEAD_TRIM }; c2 = { x: c2.x + nb.x * HEAD_TRIM, y: c2.y + nb.y * HEAD_TRIM }; } + const points = []; + for (let i = 0; i <= 24; i++) points.push(bezier(p, c1, c2, q, i / 24)); + return { + d: `M${fmt(ps.x)} ${fmt(ps.y)} C${fmt(c1.x)} ${fmt(c1.y)} ${fmt(c2.x)} ${fmt(c2.y)} ${fmt(qs.x)} ${fmt(qs.y)}`, + points, start: p, end: q, startDir, endDir, mid: bezier(p, c1, c2, q, 0.5), + }; + } + + let pts; + if (sides && a.route === "elbow") { + const horizontal = sides[0] === "left" || sides[0] === "right"; + // When the two ends overlap across the flow, a single straight segment reads better than a jog. + const lo = horizontal ? Math.max(boxA.y, boxB.y) : Math.max(boxA.x, boxB.x); + const hi = horizontal ? Math.min(boxA.y + boxA.h, boxB.y + boxB.h) : Math.min(boxA.x + boxA.w, boxB.x + boxB.w); + if (hi - lo >= (A && B ? 20 : 0)) { + const c = (lo + hi) / 2; + const end = (s, side, free) => (s ? outlinePoint(s, side, c, ARROW_GAP) : free); + pts = [end(A, sides[0], { x: a.x1, y: a.y1 }), end(B, sides[1], { x: a.x2, y: a.y2 })]; + } else { + const p = A ? sideAnchor(A, sides[0], ARROW_GAP) : { x: a.x1, y: a.y1 }; + const q = B ? sideAnchor(B, sides[1], ARROW_GAP) : { x: a.x2, y: a.y2 }; + if (horizontal) { + const mx = (p.x + q.x) / 2; + pts = [p, { x: mx, y: p.y }, { x: mx, y: q.y }, q]; + } else { + const my = (p.y + q.y) / 2; + pts = [p, { x: p.x, y: my }, { x: q.x, y: my }, q]; + } + } + pts = dedupePoints(pts); + } else { + pts = straightEnds(a, A, B); + } + if (pts.length < 2) pts = [pts[0], { ...pts[0] }]; + const start = pts[0]; + const end = pts[pts.length - 1]; + const startDir = unit(start.x - pts[1].x, start.y - pts[1].y); + const endDir = unit(end.x - pts[pts.length - 2].x, end.y - pts[pts.length - 2].y); + const drawn = trimPolyline(pts, headStart, headEnd, HEAD_TRIM); + return { d: roundedPath(drawn), points: pts, start, end, startDir, endDir, mid: pointAtHalfLength(pts) }; +} + +export function arrowLabelBox(a, g, measure = approxMeasure) { + if (!a.text || !g) return null; + const fs = fontSizeOf(a); + const lh = lineHeight(fs); + const lines = String(a.text).split("\n"); + const w = Math.max(...lines.map((l) => measure(l, fs, TEXT_WEIGHT))) + 8; + const h = lines.length * lh + 4; + return { x: g.mid.x - w / 2, y: g.mid.y - h / 2, w, h, lines, fs, lh }; +} + +// ---------- bounds and hit testing ---------- + +// The smallest and largest x and y of a pen stroke's points. Hit testing and marquee selection +// need them for every stroke on every pointer move, so they are worked out once per points array. +// Points are never changed in place: a stroke that moves gets new ones (see translateElement). +const strokeExtents = new WeakMap(); +function penExtents(points) { + let e = strokeExtents.get(points); + if (!e) { + e = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }; + for (let i = 0; i < points.length; i++) { + const x = points[i][0]; + const y = points[i][1]; + if (x < e.minX) e.minX = x; + if (x > e.maxX) e.maxX = x; + if (y < e.minY) e.minY = y; + if (y > e.maxY) e.maxY = y; + } + strokeExtents.set(points, e); + } + return e; +} + +export function elementBounds(el, byId, measure = approxMeasure) { + if (isShape(el)) return { x: el.x, y: el.y, w: el.w, h: el.h }; + if (el.type === "text") { + const l = textLayout(el, measure); + return { x: el.x, y: el.y, w: l.w, h: l.h }; + } + if (el.type === "pen") { + const e = penExtents(el.points); + const r = el.width / 2; + const x = e.minX - r; + const y = e.minY - r; + return { x, y, w: e.maxX + r - x, h: e.maxY + r - y }; + } + if (el.type === "arrow") { + const g = arrowGeometry(el, byId); + if (!g) return null; + const boxes = g.points.map((p) => ({ x: p.x, y: p.y, w: 0, h: 0 })); + const label = arrowLabelBox(el, g, measure); + if (label) boxes.push(label); + return unionBounds(boxes); + } + return null; +} + +export function unionBounds(list) { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const b of list) { + if (!b) continue; + minX = Math.min(minX, b.x); + minY = Math.min(minY, b.y); + maxX = Math.max(maxX, b.x + b.w); + maxY = Math.max(maxY, b.y + b.h); + } + return minX === Infinity ? null : { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; +} + +export function contentBounds(elements, measure = approxMeasure) { + const byId = new Map(elements.map((e) => [e.id, e])); + return unionBounds(elements.map((e) => elementBounds(e, byId, measure))); +} + +export function distToSegment(px, py, a, b) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const len2 = dx * dx + dy * dy; + const t = len2 ? Math.max(0, Math.min(1, ((px - a.x) * dx + (py - a.y) * dy) / len2)) : 0; + return Math.hypot(px - (a.x + t * dx), py - (a.y + t * dy)); +} + +export function distToPolyline(px, py, pts) { + if (pts.length === 1) return Math.hypot(px - pts[0].x, py - pts[0].y); + let best = Infinity; + for (let i = 1; i < pts.length; i++) best = Math.min(best, distToSegment(px, py, pts[i - 1], pts[i])); + return best; +} + +// How many points of a pen stroke each piece of simplifyStroke covers. +const STROKE_PIECE = 512; + +// Ramer-Douglas-Peucker simplification of a pen stroke ([x, y] points): drops points within +// `eps` of the line the rest make. The first and last points always stay. It runs on pieces of +// STROKE_PIECE points that share their ends (which stay too), because on a jagged stroke the time +// plain RDP takes can grow with the square of the number of points. In pieces it grows in step +// with it. +export function simplifyStroke(points, eps) { + if (points.length < 3) return points; + const last = points.length - 1; + const keep = new Uint8Array(points.length); + keep[last] = 1; + const stack = []; + for (let s = 0; s < last; s += STROKE_PIECE) { + keep[s] = 1; + stack.push([s, Math.min(s + STROKE_PIECE, last)]); + } + while (stack.length) { + const [a, b] = stack.pop(); + const [ax, ay] = points[a]; + const [bx, by] = points[b]; + const dx = bx - ax; + const dy = by - ay; + const len = Math.hypot(dx, dy); + let max = 0; + let idx = -1; + for (let i = a + 1; i < b; i++) { + const [px, py] = points[i]; + const d = len < 1e-6 ? Math.hypot(px - ax, py - ay) : Math.abs(dy * px - dx * py + bx * ay - by * ax) / len; + if (d > max) { + max = d; + idx = i; + } + } + if (idx > 0 && max > eps) { + keep[idx] = 1; + stack.push([a, idx], [idx, b]); + } + } + return points.filter((_, i) => keep[i]); +} + +// A stroke as it is saved: simplified, and then thinned evenly if it still has more points than +// a stroke keeps, so a long stroke loses detail rather than its end. +export function fitStroke(points, eps) { + return limitPoints(simplifyStroke(points, eps)); +} + +const inBox = (b, x, y, tol) => b && x >= b.x - tol && x <= b.x + b.w + tol && y >= b.y - tol && y <= b.y + b.h + tol; + +// Whether a point is within `reach` of a pen stroke. A stroke whose box is further away is +// skipped without looking at its points, and the rest are measured from the stored [x, y] pairs +// without making an object for each one. +function nearStroke(points, x, y, reach) { + const e = penExtents(points); + if (x < e.minX - reach || x > e.maxX + reach || y < e.minY - reach || y > e.maxY + reach) return false; + const r2 = reach * reach; + let ax = points[0][0]; + let ay = points[0][1]; + if (points.length === 1) return (x - ax) ** 2 + (y - ay) ** 2 <= r2; + for (let i = 1; i < points.length; i++) { + const bx = points[i][0]; + const by = points[i][1]; + const dx = bx - ax; + const dy = by - ay; + const len2 = dx * dx + dy * dy; + const t = len2 ? Math.max(0, Math.min(1, ((x - ax) * dx + (y - ay) * dy) / len2)) : 0; + const ex = x - (ax + t * dx); + const ey = y - (ay + t * dy); + if (ex * ex + ey * ey <= r2) return true; + ax = bx; + ay = by; + } + return false; +} + +// Topmost element under a point. Arrows are drawn on top, so they are tested first. +// Unfilled shapes only catch clicks on their outline or label unless nothing else is hit. +export function hitTest(elements, x, y, tol = 4, measure = approxMeasure) { + const byId = new Map(elements.map((e) => [e.id, e])); + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + if (el.type !== "arrow") continue; + const g = arrowGeometry(el, byId); + if (!g) continue; + if (distToPolyline(x, y, g.points) <= tol + 3 || inBox(arrowLabelBox(el, g, measure), x, y, 0)) return el; + } + let hollow = null; + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + if (el.type === "arrow") continue; + if (el.type === "pen") { + if (nearStroke(el.points, x, y, el.width / 2 + tol + 2)) return el; + } else if (el.type === "text") { + if (inBox(elementBounds(el, byId, measure), x, y, tol)) return el; + } else if (isShape(el)) { + if (el.fill !== "none") { + if (pointInShape(el, x, y, tol)) return el; + continue; + } + if (pointInShape(el, x, y, tol, "border")) return el; + if (el.text) { + const l = shapeLabelLayout(el, measure); + const w = Math.max(...l.lines.map((s) => measure(s, l.fs, LABEL_WEIGHT)), 0); + if (inBox({ x: l.cx - w / 2, y: l.top, w, h: l.lines.length * l.lh }, x, y, tol)) return el; + } + if (!hollow && pointInShape(el, x, y, tol)) hollow = el; + } + } + return hollow; +} + +// Topmost shape (not arrow/text/pen) whose area contains the point. Used for arrow binding. +export function shapeAt(elements, x, y, tol = 0, excludeId = null) { + for (let i = elements.length - 1; i >= 0; i--) { + const el = elements[i]; + if (isShape(el) && el.id !== excludeId && pointInShape(el, x, y, tol)) return el; + } + return null; +} + +export function boxesIntersect(a, b) { + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} + +export function boxContains(outer, inner) { + return inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.w <= outer.x + outer.w && inner.y + inner.h <= outer.y + outer.h; +} diff --git a/extensions/draw/lib/geometry.test.mjs b/extensions/draw/lib/geometry.test.mjs new file mode 100644 index 000000000..4d138e07c --- /dev/null +++ b/extensions/draw/lib/geometry.test.mjs @@ -0,0 +1,104 @@ +// Tests for label wrapping, arrow routes, pen strokes and hit testing. Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import test from "node:test"; +import { arrowGeometry, elementBounds, fitStroke, hitTest, simplifyStroke, wrapText } from "./geometry.mjs"; +import { MAX_PEN_POINTS, normalizeElement } from "./model.mjs"; + +// One unit of width per character, so the expected lines are easy to read. +const chars = (s) => [...s].length; + +test("labels wrap at spaces, long words are cut, and extra spaces are kept", () => { + assert.deepEqual(wrapText("the quick brown fox jumps", 11, 14, 400, chars), ["the quick", "brown fox", "jumps"]); + assert.deepEqual(wrapText("abcdefghij", 4, 14, 400, chars), ["abcd", "efgh", "ij"]); + assert.deepEqual(wrapText("ab cdefghij k", 4, 14, 400, chars), ["ab", "cdef", "ghij", "k"]); + assert.deepEqual(wrapText(" abcdefgh", 4, 14, 400, chars), ["abcd", "efgh"]); + assert.deepEqual(wrapText("a b\nc", 40, 14, 400, chars), ["a b", "c"]); + assert.deepEqual(wrapText("😀😀😀😀😀", 2, 14, 400, chars), ["😀😀", "😀😀", "😀"]); +}); + +test("a long label is wrapped without measuring each line over and over", () => { + const repeat = (n, line) => Array.from({ length: n }, () => line); + for (const [text, want] of [ + ["x".repeat(4000), [...repeat(13, "x".repeat(300)), "x".repeat(100)]], + ["i ".repeat(2000), [...repeat(13, "i ".repeat(150).trimEnd()), "i ".repeat(50)]], + ]) { + let measured = 0; + const lines = wrapText(text, 300, 14, 400, (s) => { + measured += s.length; + return chars(s); + }); + assert.deepEqual(lines, want); + // Adding one character (or word) at a time measures about 75 to 150 times the text here. + assert.ok(measured < 10 * text.length, `measured ${measured} characters to wrap ${text.length}`); + } +}); + +test("an arrow attached to nothing follows its elbow or curve route", () => { + const free = (route, x2 = 200, y2 = 100) => + arrowGeometry(normalizeElement({ id: "a", type: "arrow", x1: 0, y1: 0, x2, y2, route }), new Map()); + assert.deepEqual(free("elbow").points.map((p) => [p.x, p.y]), [[0, 0], [100, 0], [100, 100], [200, 100]]); + assert.match(free("curve").d, / C/); + assert.equal(free("straight").points.length, 2); + // Ends this close stay straight, as they do between shapes that nearly touch. + assert.equal(free("elbow", 10, 8).points.length, 2); +}); + +test("a simplified stroke keeps its ends and corners and drops points on a straight line", () => { + const line = Array.from({ length: 500 }, (_, i) => [i, 0]); + assert.deepEqual(simplifyStroke(line, 0.5), [[0, 0], [499, 0]]); + const corner = [...line.slice(0, 250), ...Array.from({ length: 250 }, (_, i) => [249, i + 1])]; + assert.deepEqual(simplifyStroke(corner, 0.5), [[0, 0], [249, 0], [249, 250]]); + assert.deepEqual(simplifyStroke([[0, 0], [5, 5]], 0.5), [[0, 0], [5, 5]]); + // A long stroke is done in pieces, and the points where they meet stay: one per 512 points. + const long = Array.from({ length: 5000 }, (_, i) => [i, 0]); + assert.deepEqual(simplifyStroke(long, 0.5).map((p) => p[0]), [0, 512, 1024, 1536, 2048, 2560, 3072, 3584, 4096, 4608, 4999]); +}); + +test("simplifying a long jagged stroke takes time in step with its length", () => { + // A zigzag that gets wider as it goes makes plain RDP split one point off at a time, so it + // would check about n * n / 2 distances: over a billion here. + const zigzag = Array.from({ length: 50000 }, (_, i) => [i, i % 2 ? -i : i]); + const start = performance.now(); + const out = simplifyStroke(zigzag, 0.5); + assert.ok(performance.now() - start < 1500, `took ${Math.round(performance.now() - start)} ms`); + // No point is on a line, so every one stays. + assert.equal(out.length, zigzag.length); +}); + +test("a fitted stroke is short enough to keep and ends where the pointer did", () => { + const zigzag = Array.from({ length: 30000 }, (_, i) => [i, i % 2 ? -i : i]); + const out = fitStroke(zigzag, 0.5); + assert.equal(out.length, MAX_PEN_POINTS); + assert.deepEqual(out[0], zigzag[0]); + assert.deepEqual(out.at(-1), zigzag.at(-1)); + assert.equal(normalizeElement({ type: "pen", points: out }).points.length, MAX_PEN_POINTS); +}); + +test("pointing finds a pen stroke without reading the points of strokes out of reach", () => { + const near = normalizeElement({ id: "near", type: "pen", points: [[0, 0], [100, 0], [100, 100]], width: 4 }); + const dot = normalizeElement({ id: "dot", type: "pen", points: [[300, 300]], width: 4 }); + const far = normalizeElement({ id: "far", type: "pen", points: Array.from({ length: MAX_PEN_POINTS }, (_, i) => [1000 + i, 1000]), width: 4 }); + let reads = 0; + const points = new Proxy(far.points, { + get(target, key, receiver) { + if (typeof key === "string" && /^\d+$/.test(key)) reads++; + return Reflect.get(target, key, receiver); + }, + }); + const elements = [near, dot, { ...far, points }]; + + // Within half the width plus the tolerance plus 2 (8 here) of a line between two points. + assert.equal(hitTest(elements, 50, 5)?.id, "near"); + reads = 0; + assert.equal(hitTest(elements, 50, 9), null); + assert.equal(hitTest(elements, 50, 5, 0), null); + assert.equal(hitTest(elements, 105, 50)?.id, "near"); + assert.equal(hitTest(elements, 303, 303)?.id, "dot"); + assert.equal(hitTest(elements, 306, 306), null); + assert.equal(reads, 0); + assert.equal(hitTest(elements, 1500, 1006)?.id, "far"); + + assert.deepEqual(elementBounds(near), { x: -2, y: -2, w: 104, h: 104 }); + const moved = { ...near, points: near.points.map(([x, y]) => [x + 50, y]) }; + assert.deepEqual(elementBounds(moved), { x: 48, y: -2, w: 104, h: 104 }); +}); diff --git a/extensions/draw/lib/layout.mjs b/extensions/draw/lib/layout.mjs new file mode 100644 index 000000000..c19c59b98 --- /dev/null +++ b/extensions/draw/lib/layout.mjs @@ -0,0 +1,633 @@ +// Automatic placement: layered graph layout, label-based sizing, free-spot search, +// and turning an agent's { nodes, edges, texts } spec into positioned elements. +import { + DEFAULT_SIZES, FONT_SIZES, LABEL_WEIGHT, ID_PATTERN, MAX_COORD, MAX_SIDE, isShape, isNum, snap, + normalizeElement, normalizeSize, normalizeText, resolveShapeType, uniqueId, +} from "./model.mjs"; +import { + LABEL_PAD, approxMeasure, lineHeight, wrapText, labelMaxWidth, neededHeight, elementBounds, unionBounds, textLayout, +} from "./geometry.mjs"; + +const ceil20 = (v) => Math.ceil(v / 20) * 20; +const lower = (v) => (typeof v === "string" ? v.trim().toLowerCase() : ""); +export const DIRECTIONS = ["right", "down", "left", "up"]; + +// The most placeholders a layout may make (see layeredLayout). An arrow gets one on each layer it +// passes, and each one takes part in every sweep, so a long chain with many arrows across it could +// otherwise need millions and keep the extension busy for most of a minute. +export const MAX_PLACEHOLDERS = 100000; + +// Thrown by layeredLayout when a drawing is too big to lay out. The message says why. +export class LayoutError extends Error {} + +// Width/height that fit a label, in multiples of 20 so centers stay on the grid. +export function autoSize(text, type, measure = approxMeasure, size = "m") { + const [dw, dh] = DEFAULT_SIZES[type] || DEFAULT_SIZES.rect; + const str = String(text || ""); + if (!str.trim()) return { w: dw, h: dh }; + const fs = FONT_SIZES[size] || FONT_SIZES.m; + const widest = Math.max(...str.split("\n").map((l) => measure(l, fs, LABEL_WEIGHT))); + let need = widest + 2 * LABEL_PAD + 4; + if (type === "ellipse") need = (widest + 12) / 0.72; + if (type === "diamond") need = (widest + 12) / 0.56; + const w = Math.min(Math.max(ceil20(need), dw), type === "rect" ? 280 : 240); + const h = Math.min(MAX_SIDE, Math.max(dh, ceil20(neededHeight({ type, x: 0, y: 0, w, h: dh, size, text: str }, measure)))); + return { w, h }; +} + +// 1D placement: keeps order, respects minimum gaps, and stays as close as possible to the +// targets (weighted, so real nodes win over edge placeholders). +function placeRow(desired, seps, weights) { + const n = desired.length; + const offset = [0]; + for (let i = 1; i < n; i++) offset[i] = offset[i - 1] + seps[i - 1]; + const blocks = []; + for (let i = 0; i < n; i++) { + const w = weights[i]; + blocks.push({ sum: (desired[i] - offset[i]) * w, weight: w, start: i }); + while (blocks.length > 1) { + const b = blocks[blocks.length - 1]; + const a = blocks[blocks.length - 2]; + if (a.sum / a.weight <= b.sum / b.weight) break; + a.sum += b.sum; + a.weight += b.weight; + blocks.pop(); + } + } + const out = new Array(n); + blocks.forEach((b, k) => { + const end = k + 1 < blocks.length ? blocks[k + 1].start : n; + for (let i = b.start; i < end; i++) out[i] = b.sum / b.weight + offset[i]; + }); + return out; +} + +// Crossings between two neighboring layers. upper lists the upper layer's vertices in order, +// down[u] the lower-layer vertices that u links to, pos[v] a lower vertex's position, and +// lowerSize how many vertices the lower layer has. +// Two edges cross when their ends are in opposite orders. Taking the edges in order of their upper +// end, each one crosses the earlier edges whose lower end is further along. A Fenwick tree counts +// those in O(log n), so this is O(E log V) rather than comparing every pair of edges. +export function countCrossings(upper, down, pos, lowerSize) { + const tree = new Uint32Array(lowerSize + 1); + let crossings = 0; + let seen = 0; + const add = (b) => { + let atOrBefore = 0; + for (let i = b + 1; i > 0; i -= i & -i) atOrBefore += tree[i]; + crossings += seen - atOrBefore; + for (let i = b + 1; i <= lowerSize; i += i & -i) tree[i]++; + seen++; + }; + for (const u of upper) { + const targets = down[u]; + // Edges from the same vertex never cross each other, so they go in increasing order. + if (targets.length === 1) add(pos[targets[0]]); + else if (targets.length > 1) for (const b of targets.map((v) => pos[v]).sort((x, y) => x - y)) add(b); + } + return crossings; +} + +// Layered (Sugiyama-style) layout. nodes: [{id, w, h}], edges: [{from, to, label?}]. +// Returns Map id -> {x, y} (top-left), with the layout's top-left corner at 0,0. Throws a +// LayoutError when it would need more than maxPlaceholders placeholders. +export function layeredLayout(nodes, edges, { direction = "right", measure = approxMeasure, maxPlaceholders = MAX_PLACEHOLDERS } = {}) { + const horizontal = direction === "right" || direction === "left"; + const N = nodes.length; + const result = new Map(); + if (!N) return result; + const index = new Map(nodes.map((n, i) => [n.id, i])); + const succ = Array.from({ length: N }, () => new Set()); + let labelSpace = 0; + for (const e of edges) { + const a = index.get(e.from); + const b = index.get(e.to); + if (a === undefined || b === undefined || a === b) continue; + succ[a].add(b); + if (e.label) labelSpace = Math.max(labelSpace, horizontal ? measure(e.label, FONT_SIZES.s, 400) + 40 : 30); + } + const connected = new Array(N).fill(false); + succ.forEach((set, a) => set.forEach((b) => { connected[a] = true; connected[b] = true; })); + + // Break cycles with a DFS, reversing back edges. Sources go first so cycles break naturally. + const indeg0 = new Array(N).fill(0); + succ.forEach((set) => set.forEach((b) => indeg0[b]++)); + const roots = [...Array(N).keys()].sort((a, b) => (indeg0[a] === 0 ? 0 : 1) - (indeg0[b] === 0 ? 0 : 1) || a - b); + const mark = new Uint8Array(N); + const dag = Array.from({ length: N }, () => new Set()); + for (const root of roots) { + if (mark[root]) continue; + mark[root] = 1; + const stack = [[root, [...succ[root]], 0]]; + while (stack.length) { + const top = stack[stack.length - 1]; + if (top[2] < top[1].length) { + const v = top[1][top[2]++]; + const u = top[0]; + if (mark[v] === 1) dag[v].add(u); + else { + dag[u].add(v); + if (mark[v] === 0) { mark[v] = 1; stack.push([v, [...succ[v]], 0]); } + } + } else { + mark[top[0]] = 2; + stack.pop(); + } + } + } + + // Longest-path layering, then pull sources next to their first child. + const preds = Array.from({ length: N }, () => []); + dag.forEach((set, u) => set.forEach((v) => preds[v].push(u))); + const indeg = preds.map((p) => p.length); + const layer = new Array(N).fill(0); + const queue = []; + for (let i = 0; i < N; i++) if (connected[i] && !indeg[i]) queue.push(i); + const topo = []; + while (queue.length) { + const u = queue.shift(); + topo.push(u); + for (const v of dag[u]) { + layer[v] = Math.max(layer[v], layer[u] + 1); + if (--indeg[v] === 0) queue.push(v); + } + } + for (let i = topo.length - 1; i >= 0; i--) { + const u = topo[i]; + if (!preds[u].length && dag[u].size) layer[u] = Math.min(...[...dag[u]].map((v) => layer[v])) - 1; + } + + // Placeholders are counted before any are made (see MAX_PLACEHOLDERS), so turning down a drawing + // that needs too many costs only this loop. + let placeholders = 0; + for (let u = 0; u < N; u++) for (const v of dag[u]) placeholders += layer[v] - layer[u] - 1; + if (placeholders > maxPlaceholders) { + const count = (n) => n.toLocaleString("en-US"); + throw new LayoutError( + `The layout is too big to work out: an arrow needs a placeholder on each layer of shapes it passes, and this diagram needs ${count(placeholders)}, more than the ${count(maxPlaceholders)} automatic layout handles. Arrows that jump across many layers, like ones back to the start of a long chain, need the most. Split the diagram into smaller ones, or leave out some of those arrows.`, + ); + } + + // Layers with dummy vertices for edges that span several layers. + const layers = []; + const up = []; + const down = []; + const isDummy = []; + const addVertex = (li, dummy) => { + const v = up.length; + up.push([]); + down.push([]); + isDummy.push(dummy); + (layers[li] ||= []).push(v); + return v; + }; + const vertexOf = new Array(N); + for (let i = 0; i < N; i++) if (connected[i]) vertexOf[i] = addVertex(layer[i], false); + const realOf = new Map(); + vertexOf.forEach((v, i) => { if (v !== undefined) realOf.set(v, i); }); + for (let u = 0; u < N; u++) { + for (const v of dag[u]) { + let prev = vertexOf[u]; + for (let li = layer[u] + 1; li < layer[v]; li++) { + const d = addVertex(li, true); + down[prev].push(d); + up[d].push(prev); + prev = d; + } + down[prev].push(vertexOf[v]); + up[vertexOf[v]].push(prev); + } + } + for (let li = 0; li < layers.length; li++) layers[li] ||= []; + + // Crossing reduction: barycenter sweeps, keeping the best ordering seen. Per-vertex numbers live + // in typed arrays, since long edges can add up to maxPlaceholders placeholder vertices. + const pos = new Int32Array(up.length); + const setPos = () => layers.forEach((L) => L.forEach((v, i) => { pos[v] = i; })); + setPos(); + const crossings = () => { + let c = 0; + for (let li = 0; li < layers.length - 1; li++) c += countCrossings(layers[li], down, pos, layers[li + 1].length); + return c; + }; + let best = layers.map((L) => [...L]); + let bestC = crossings(); + const bc = new Float64Array(up.length); + for (let iter = 0; iter < 12 && bestC > 0; iter++) { + const downward = iter % 2 === 0; + const order = [...layers.keys()]; + if (!downward) order.reverse(); + for (const li of order.slice(1)) { + for (const v of layers[li]) { + const nb = downward ? up[v] : down[v]; + let sum = 0; + for (const n of nb) sum += pos[n]; + bc[v] = nb.length ? sum / nb.length : pos[v]; + } + layers[li].sort((a, b) => bc[a] - bc[b] || pos[a] - pos[b]); + layers[li].forEach((v, i) => { pos[v] = i; }); + } + const c = crossings(); + if (c < bestC) { bestC = c; best = layers.map((L) => [...L]); } + } + best.forEach((L, li) => { layers[li] = L; }); + setPos(); + + // Coordinates. "main" runs along the flow, "cross" across it. + const size = (v, main) => { + if (isDummy[v]) return 0; + const n = nodes[realOf.get(v)]; + return horizontal === main ? n.w : n.h; + }; + const rankGap = Math.max(horizontal ? 100 : 80, labelSpace); + const nodeGap = horizontal ? 50 : 60; + const mainCenter = []; + let cursor = 0; + for (const L of layers) { + const m = Math.max(0, ...L.map((v) => size(v, true))); + mainCenter.push(cursor + m / 2); + cursor += m + rankGap; + } + const gapBetween = (a, b) => (isDummy[a] && isDummy[b] ? 10 : isDummy[a] || isDummy[b] ? nodeGap / 2 : nodeGap); + const seps = (L) => L.slice(1).map((v, i) => (size(L[i], false) + size(v, false)) / 2 + gapBetween(L[i], v)); + const cross = new Float64Array(up.length); + for (const L of layers) { + const s = seps(L); + const total = s.reduce((a, b) => a + b, 0); + let c = -total / 2; + L.forEach((v, i) => { if (i) c += s[i - 1]; cross[v] = c; }); + } + // Edge placeholders follow real nodes, not the other way around. + const desiredOf = (v, nb) => { + let sum = 0; + let weight = 0; + for (const n of nb) { + const k = isDummy[n] ? 0.1 : 1; + sum += cross[n] * k; + weight += k; + } + return weight ? sum / weight : cross[v]; + }; + for (let iter = 0; iter < 10; iter++) { + const mode = iter === 9 ? "both" : iter % 2 === 0 ? "down" : "up"; + const order = [...layers.keys()]; + if (mode === "up") order.reverse(); + for (const li of order) { + const L = layers[li]; + if (!L.length) continue; + const desired = L.map((v) => desiredOf(v, mode === "down" ? up[v] : mode === "up" ? down[v] : [...up[v], ...down[v]])); + placeRow(desired, seps(L), L.map((v) => (isDummy[v] ? 0.05 : 1))).forEach((c, i) => { cross[L[i]] = c; }); + } + } + // Straighten one-to-one links at the ends of a flow: a node with a single link, whose neighbor + // has no other link on that side, moves into line with it if there is room. Nothing else moves. + for (const L of layers) { + const s = seps(L); + L.forEach((v, i) => { + if (isDummy[v] || up[v].length + down[v].length !== 1) return; + const n = up[v].length ? up[v][0] : down[v][0]; + if ((up[v].length ? down[n] : up[n]).length !== 1) return; + let c = cross[n]; + if (i > 0) c = Math.max(c, cross[L[i - 1]] + s[i - 1]); + if (i < L.length - 1) c = Math.min(c, cross[L[i + 1]] - s[i]); + cross[v] = c; + }); + } + + const flipMain = direction === "left" || direction === "up"; + for (const [v, i] of realOf) { + const n = nodes[i]; + const li = layer[i]; + const m = flipMain ? -mainCenter[li] : mainCenter[li]; + const cx = snap(horizontal ? m : cross[v]); + const cy = snap(horizontal ? cross[v] : m); + result.set(n.id, { x: cx - n.w / 2, y: cy - n.h / 2 }); + } + + // Unconnected nodes go in a grid after the connected part. + const loose = nodes.filter((n, i) => !connected[i]); + if (loose.length) { + const placed = unionBounds([...result.entries()].map(([id, p]) => { + const n = nodes[index.get(id)]; + return { x: p.x, y: p.y, w: n.w, h: n.h }; + })); + const cols = Math.min(4, Math.ceil(Math.sqrt(loose.length))); + const cellW = Math.max(...loose.map((n) => n.w)) + 60; + const cellH = Math.max(...loose.map((n) => n.h)) + 60; + const ox = placed ? placed.x : 0; + const oy = placed ? placed.y + placed.h + 80 : 0; + loose.forEach((n, k) => { + const cx = snap(ox + (k % cols) * cellW + cellW / 2); + const cy = snap(oy + Math.floor(k / cols) * cellH + cellH / 2); + result.set(n.id, { x: cx - n.w / 2, y: cy - n.h / 2 }); + }); + } + + const all = unionBounds([...result.entries()].map(([id, p]) => ({ x: p.x, y: p.y, w: 0, h: 0 }))); + const dx = snap(all.x); + const dy = snap(all.y); + for (const p of result.values()) { p.x -= dx; p.y -= dy; } + return result; +} + +// Finds a spot for a new w x h box next to an anchor box in a direction, avoiding obstacles. +// When every spot near the anchor is taken, it goes just past everything, in line with the anchor. +export function findFreeSpot(anchor, dir, w, h, obstacles, gap = 60) { + const horizontal = dir === "left" || dir === "right"; + const forward = dir === "right" || dir === "down"; + const cx = anchor.x + anchor.w / 2; + const cy = anchor.y + anchor.h / 2; + // How far apart the new box and any obstacle stay. + const pad = 20; + const blocked = (x, y) => obstacles.some((o) => x < o.x + o.w + pad && x + w > o.x - pad && y < o.y + o.h + pad && y + h > o.y - pad); + for (let step = 0; step < 6; step++) { + for (const k of [0, 1, -1, 2, -2]) { + let x; + let y; + if (horizontal) { + x = forward ? anchor.x + anchor.w + gap + step * (w + gap) : anchor.x - gap - w - step * (w + gap); + y = cy - h / 2 + k * (h + 30); + } else { + y = forward ? anchor.y + anchor.h + gap + step * (h + gap) : anchor.y - gap - h - step * (h + gap); + x = cx - w / 2 + k * (w + 30); + } + if (!blocked(x, y)) return { x, y }; + } + } + const all = unionBounds([anchor, ...obstacles]); + const room = Math.max(gap, pad); + if (horizontal) return { x: forward ? all.x + all.w + room : all.x - room - w, y: cy - h / 2 }; + return { x: cx - w / 2, y: forward ? all.y + all.h + room : all.y - room - h }; +} + +export const oppositeDir = (d) => ({ right: "left", left: "right", up: "down", down: "up" })[d] || "left"; + +export function slugId(label, taken, fallback = "node") { + let base = lower(label).replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 24).replace(/-+$/, ""); + if (!base) base = fallback; + let id = base; + for (let i = 2; taken.has(id); i++) id = `${base}-${i}`; + taken.add(id); + return id; +} + +// x and y come together: with both, the item goes there, and with neither it is placed for you. +// Anything else goes in errors, because placing the item anyway would drop the coordinate given. +function pinnedAt(item, name, auto, errors) { + const hasX = item.x != null; + const hasY = item.y != null; + if (hasX !== hasY) errors.push(`${name} has only ${hasX ? "x" : "y"}. Give x and y together, or leave both out ${auto}.`); + else if (hasX && !(isNum(item.x) && isNum(item.y))) errors.push(`${name} needs numbers for x and y.`); + else return hasX; + return false; +} + +// Runs layeredLayout. Returns { pos }, or { error } when the drawing is too big for it. +function layOut(nodes, edges, options) { + try { + return { pos: layeredLayout(nodes, edges, options), error: null }; + } catch (err) { + if (err instanceof LayoutError) return { pos: null, error: err.message }; + throw err; + } +} + +// Converts { nodes, edges, texts } into elements. With `existing`, new nodes are placed next to +// the existing nodes they connect to, or beside the current content. +// Returns { elements, errors }. Nothing should be applied when errors is non-empty. +export function buildFromSpec(spec, { existing = [], direction = "right", measure = approxMeasure } = {}) { + const errors = []; + const taken = new Set(existing.map((e) => e.id)); + const existingShapes = existing.filter(isShape); + const nodes = []; + const pinned = new Set(); + + for (const n of spec.nodes || []) { + const label = normalizeText(n.label ?? n.text); + const type = resolveShapeType(n.shape || n.type); + let id; + if (n.id != null && String(n.id) !== "") { + id = String(n.id); + if (!ID_PATTERN.test(id)) { errors.push(`Node id "${id}" is not valid. Use letters, numbers, dashes or underscores.`); continue; } + if (taken.has(id)) { errors.push(`Node id "${id}" is already used. Pick another id, or use update_elements to change the existing one.`); continue; } + taken.add(id); + } else { + id = slugId(label, taken); + } + const size = normalizeSize(n.size); + const auto = autoSize(label, type, measure, size); + const el = normalizeElement({ + id, type, text: label, color: n.color, fill: n.fill, dash: n.dashed ?? n.dash, size, + x: n.x, y: n.y, w: n.w ?? auto.w, h: n.h ?? auto.h, + }); + if (pinnedAt(n, `Node "${id}"`, "for automatic layout", errors)) pinned.add(id); + nodes.push(el); + } + + const allShapes = [...existingShapes, ...nodes]; + const byId = new Map(allShapes.map((s) => [s.id, s])); + // Label to id, or null when several shapes share the label. Built on the first label lookup. + let byLabel = null; + const resolveRef = (ref) => { + if (typeof ref !== "string" || !ref) return null; + if (byId.has(ref)) return ref; + if (!byLabel) { + byLabel = new Map(); + for (const s of allShapes) { + const key = lower(s.text); + if (key) byLabel.set(key, byLabel.has(key) ? null : s.id); + } + } + return byLabel.get(lower(ref)) || null; + }; + const edges = []; + for (const e of spec.edges || []) { + const from = resolveRef(e.from); + const to = resolveRef(e.to); + if (!from || !to) { + errors.push(`Edge "${e.from}" -> "${e.to}": no node with id or label "${!from ? e.from : e.to}".`); + continue; + } + if (from === to) { errors.push(`Edge "${e.from}" -> "${e.to}" connects a node to itself, which is not supported.`); continue; } + let id = e.id != null && String(e.id) !== "" ? String(e.id) : null; + if (id && (!ID_PATTERN.test(id) || taken.has(id))) { errors.push(`Edge id "${id}" is not valid or already used.`); continue; } + if (id) taken.add(id); + else id = uniqueId("a", taken); + edges.push(normalizeElement({ + id, type: "arrow", from, to, text: e.label ?? e.text, color: e.color, dash: e.dashed ?? e.dash, head: e.head, route: e.route, + })); + } + + // Place nodes that have no x/y. + const existingById = new Map(existing.map((e) => [e.id, e])); + const obstacles = existing.map((e) => elementBounds(e, existingById, measure)).filter(Boolean); + for (const n of nodes) if (pinned.has(n.id)) obstacles.push({ x: n.x, y: n.y, w: n.w, h: n.h }); + const anchored = new Map([...existingShapes, ...nodes.filter((n) => pinned.has(n.id))].map((s) => [s.id, s])); + const unplaced = nodes.filter((n) => !pinned.has(n.id)); + if (anchored.size) { + for (let progress = true; progress;) { + progress = false; + for (const n of unplaced) { + if (anchored.has(n.id)) continue; + const link = edges.find((e) => (e.to === n.id && anchored.has(e.from)) || (e.from === n.id && anchored.has(e.to))); + if (!link) continue; + const outgoing = link.to === n.id; + const anchor = anchored.get(outgoing ? link.from : link.to); + const spot = findFreeSpot(anchor, outgoing ? direction : oppositeDir(direction), n.w, n.h, obstacles); + n.x = spot.x; + n.y = spot.y; + anchored.set(n.id, n); + obstacles.push({ x: n.x, y: n.y, w: n.w, h: n.h }); + progress = true; + } + } + } + const rest = unplaced.filter((n) => !anchored.has(n.id)); + if (rest.length) { + const restIds = new Set(rest.map((n) => n.id)); + const { pos, error } = layOut(rest, edges.filter((e) => restIds.has(e.from) && restIds.has(e.to)).map((e) => ({ from: e.from, to: e.to, label: e.text })), { direction, measure }); + if (error) errors.push(error); + const content = unionBounds(obstacles); + let ox = 0; + let oy = 0; + if (content) { + const vertical = direction === "down" || direction === "up"; + ox = snap(vertical ? content.x : content.x + content.w + 120); + oy = snap(vertical ? content.y + content.h + 100 : content.y); + } + // Without a layout the error above means nothing is applied, so they can stay where they are. + if (pos) { + for (const n of rest) { + const p = pos.get(n.id); + n.x = ox + p.x; + n.y = oy + p.y; + } + } + } + + const texts = []; + const shapeBox = unionBounds([...existing, ...nodes].filter(isShape).map((s) => ({ x: s.x, y: s.y, w: s.w, h: s.h }))); + let textCursor = shapeBox ? shapeBox.y - 30 : 0; + for (const t of spec.texts || []) { + const text = normalizeText(t.text ?? t.label); + if (!text.trim()) { errors.push("A text item needs non-empty text."); continue; } + let id = t.id != null && String(t.id) !== "" ? String(t.id) : null; + if (id && (!ID_PATTERN.test(id) || taken.has(id))) { errors.push(`Text id "${id}" is not valid or already used.`); continue; } + if (id) taken.add(id); + const words = text.replace(/\s+/g, " ").trim(); + const name = `Text "${id || (words.length > 40 ? `${words.slice(0, 37)}...` : words)}"`; + const placed = pinnedAt(t, name, "to put it above the diagram", errors); + if (!id) id = uniqueId("t", taken); + const el = normalizeElement({ id, type: "text", text, color: t.color, size: t.size ?? "l", x: t.x, y: t.y }); + if (!placed) { + const l = textLayout(el, measure); + textCursor -= l.h; + el.x = shapeBox ? shapeBox.x : 0; + el.y = Math.round(textCursor); + textCursor -= 12; + } + texts.push(el); + } + const far = rangeError([...nodes, ...texts]); + if (far) errors.push(far); + return { elements: [...nodes, ...edges, ...texts], errors }; +} + +// Saving moves a position past MAX_COORD back to it, on top of whatever else is there, so a +// layout that puts a shape or text that far out (very long arrow labels spread the layers far +// apart, for one) has to be turned down. Says which element it is, or returns null. +function rangeError(elements) { + const far = elements.find((e) => (isShape(e) || e.type === "text") && (Math.abs(e.x) > MAX_COORD || Math.abs(e.y) > MAX_COORD)); + if (!far) return null; + const limit = MAX_COORD.toLocaleString("en-US"); + return `The layout does not fit: "${far.id}" would be at ${Math.round(far.x)},${Math.round(far.y)}, but positions only go from -${limit} to ${limit}. Shorten the longest labels, or split the diagram into smaller ones.`; +} + +// Re-lays out all shapes in a drawing, keeping the top-left corner where it was. +// Returns { elements, error }. Nothing should be applied when error is set. +export function relayout(elements, { direction = "right", measure = approxMeasure } = {}) { + const shapes = elements.filter(isShape); + if (!shapes.length) return { elements, error: null }; + const before = unionBounds(shapes.map((s) => ({ x: s.x, y: s.y, w: s.w, h: s.h }))); + const edges = elements.filter((e) => e.type === "arrow" && e.from && e.to).map((e) => ({ from: e.from, to: e.to, label: e.text })); + const { pos, error } = layOut(shapes, edges, { direction, measure }); + if (error) return { elements, error }; + const ox = snap(before.x); + const oy = snap(before.y); + const arranged = elements.map((e) => (isShape(e) && pos.has(e.id) ? { ...e, x: ox + pos.get(e.id).x, y: oy + pos.get(e.id).y } : e)); + return { elements: arranged, error: rangeError(arranged) }; +} + +// Labels longer than this are cut short in outlines. The element JSON has them in full. +export const OUTLINE_LABEL_MAX = 300; + +const quote = (s) => { + const flat = String(s).replace(/\n/g, " / "); + if (flat.length <= OUTLINE_LABEL_MAX) return JSON.stringify(flat); + return `${JSON.stringify(`${flat.slice(0, OUTLINE_LABEL_MAX)}...`)} (cut from ${String(s).length} characters)`; +}; + +const OUTLINE_GROUPS = [ + ["Shapes", isShape, (s) => { + const style = [s.type, s.color !== "gray" ? s.color : null, s.fill !== "soft" ? `fill ${s.fill}` : null, s.dash ? "dashed" : null, s.size !== "m" ? `text size ${s.size}` : null].filter(Boolean).join(", "); + return `- ${s.id} (${style}) ${s.text ? quote(s.text) : "(no label)"} at ${Math.round(s.x)},${Math.round(s.y)} size ${Math.round(s.w)}x${Math.round(s.h)}`; + }], + ["Arrows", (e) => e.type === "arrow", (a) => { + const from = a.from || `(${Math.round(a.x1)},${Math.round(a.y1)})`; + const to = a.to || `(${Math.round(a.x2)},${Math.round(a.y2)})`; + const style = [a.head !== "end" ? `head ${a.head}` : null, a.route !== "straight" ? a.route : null, a.dash ? "dashed" : null, a.color !== "gray" ? a.color : null, a.size !== "s" ? `text size ${a.size}` : null].filter(Boolean).join(", "); + return `- ${a.id}: ${from} -> ${to}${a.text ? ` ${quote(a.text)}` : ""}${style ? ` (${style})` : ""}`; + }], + ["Text", (e) => e.type === "text", (t) => { + const style = [t.color !== "gray" ? t.color : null, t.size !== "l" ? `text size ${t.size}` : null].filter(Boolean).join(", "); + return `- ${t.id}${style ? ` (${style})` : ""}: ${quote(t.text)} at ${Math.round(t.x)},${Math.round(t.y)}`; + }], + ["Pen strokes", (e) => e.type === "pen", (p) => { + const b = elementBounds(p, null); + const style = [p.color !== "gray" ? p.color : null, `width ${p.width}`].filter(Boolean).join(", "); + return `- ${p.id} (${style}) ${p.points.length} points at ${Math.round(b.x)},${Math.round(b.y)} size ${Math.round(b.w)}x${Math.round(b.h)}`; + }], +]; + +// A readable outline of a drawing for the agent: a count of each kind of element, then one line +// per element with its id, label, position and style (shapes, then arrows, text and pen strokes). +// A style is left out when it is the default (see the size, color and other inputs in actions.mjs). +// A big drawing comes in parts. The text stops before it would pass `budget` characters, where +// `cost(el)` adds what a caller sends along with an element, but a part always has at least one +// element. `start` is where in the list to begin, and `next` is where the following part begins, +// or null after the last part. `only` limits the list to a set of ids. +export function outlinePage(doc, { start = 0, budget = Infinity, only = null, cost = null } = {}) { + const els = doc.elements || []; + const groups = OUTLINE_GROUPS.map(([title, test, line]) => ({ title, line, list: els.filter(test) })); + const [shapes, arrows, texts, pens] = groups.map((g) => g.list.length); + const header = `Drawing ${quote(doc.name)}: ${shapes} shapes, ${arrows} arrows, ${texts} text, ${pens} pen strokes.`; + const order = groups.flatMap((g) => g.list.filter((el) => !only || only.has(el.id)).map((el) => ({ el, g }))); + const lines = [header]; + const shown = []; + let used = header.length; + let group = null; + let next = null; + for (let i = Math.max(0, start); i < order.length; i++) { + const { el, g } = order[i]; + const line = g.line(el); + const heading = g === group ? null : `${g.title}:`; + const size = line.length + 1 + (heading ? heading.length + 1 : 0) + (cost ? cost(el) : 0); + if (shown.length && used + size > budget) { + next = i; + break; + } + if (heading) lines.push(heading); + lines.push(line); + group = g; + used += size; + shown.push(el); + } + return { text: lines.join("\n"), next, total: order.length, shown }; +} + +// The whole outline as text, with long labels cut short. +export function describe(doc) { + return outlinePage(doc).text; +} + +export { wrapText, labelMaxWidth, lineHeight }; diff --git a/extensions/draw/lib/layout.test.mjs b/extensions/draw/lib/layout.test.mjs new file mode 100644 index 000000000..16f95ea38 --- /dev/null +++ b/extensions/draw/lib/layout.test.mjs @@ -0,0 +1,225 @@ +// Tests for turning a { nodes, edges } spec into elements, for the layout's crossing count and +// size limit, and for the outline the agent reads. +// Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import test from "node:test"; +import { boxesIntersect, elementBounds } from "./geometry.mjs"; +import { LayoutError, buildFromSpec, countCrossings, findFreeSpot, layeredLayout, outlinePage } from "./layout.mjs"; +import { normalizeElement } from "./model.mjs"; + +const shape = (id, text, x) => normalizeElement({ id, type: "rect", x, y: 0, text }); +const links = (elements) => elements.filter((e) => e.type === "arrow").map((a) => [a.from, a.to]); + +test("edges can name nodes by id or by label, in any letter case", () => { + const { elements, errors } = buildFromSpec({ + nodes: [{ id: "api", label: "API" }, { label: "Orders Database" }], + edges: [{ from: "api", to: "orders database" }, { from: "ORDERS DATABASE", to: "API" }], + }); + assert.deepEqual(errors, []); + const db = elements.find((e) => e.text === "Orders Database"); + assert.deepEqual(links(elements), [["api", db.id], [db.id, "api"]]); +}); + +test("a label that several shapes share has to be referenced by id", () => { + const existing = [shape("c1", "Cache", 0), shape("c2", "cache", 300)]; + const byLabel = buildFromSpec({ nodes: [{ id: "web", label: "Web" }], edges: [{ from: "web", to: "Cache" }] }, { existing }); + assert.equal(byLabel.errors.length, 1); + assert.match(byLabel.errors[0], /no node with id or label "Cache"/); + + const byId = buildFromSpec({ nodes: [{ id: "web", label: "Web" }], edges: [{ from: "web", to: "c2" }] }, { existing }); + assert.deepEqual(byId.errors, []); + assert.deepEqual(links(byId.elements), [["web", "c2"]]); +}); + +test("a blank reference never matches an unlabeled shape", () => { + const existing = [shape("s1", "", 0)]; + const { errors } = buildFromSpec({ nodes: [{ id: "n", label: "New" }], edges: [{ from: " ", to: "n" }] }, { existing }); + assert.equal(errors.length, 1); +}); + +test("x and y come together, so one without the other is an error and not dropped", () => { + const partial = buildFromSpec({ + nodes: [{ id: "a", label: "A", x: 100 }, { id: "b", label: "B", y: 50 }], + edges: [{ from: "a", to: "b" }], + texts: [{ text: "Sign-in\nflow", x: 10 }], + }); + assert.deepEqual(partial.errors, [ + 'Node "a" has only x. Give x and y together, or leave both out for automatic layout.', + 'Node "b" has only y. Give x and y together, or leave both out for automatic layout.', + 'Text "Sign-in flow" has only x. Give x and y together, or leave both out to put it above the diagram.', + ]); + const strings = buildFromSpec({ nodes: [{ id: "a", label: "A", x: "100", y: 0 }] }); + assert.deepEqual(strings.errors, ['Node "a" needs numbers for x and y.']); + + const { elements, errors } = buildFromSpec({ + nodes: [{ id: "a", label: "A", x: 120, y: 40 }, { id: "b", label: "B" }], + edges: [{ from: "a", to: "b" }], + texts: [{ text: "Note", x: 5, y: 6 }], + }); + assert.deepEqual(errors, []); + const at = (el) => [el.x, el.y]; + assert.deepEqual(at(elements.find((e) => e.id === "a")), [120, 40]); + assert.deepEqual(at(elements.find((e) => e.text === "Note")), [5, 6]); +}); + +test("new nodes are placed clear of everything already on the canvas", () => { + const existing = [ + shape("a", "A", 0), + shape("b", "B", 400), + normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b", text: "calls" }), + normalizeElement({ id: "note", type: "text", x: 200, y: 100, text: "A note under the arrow" }), + ]; + const { elements, errors } = buildFromSpec({ + nodes: [{ id: "c", label: "C" }, { id: "d", label: "D" }], + edges: [{ from: "a", to: "c" }, { from: "c", to: "d" }], + }, { existing }); + assert.deepEqual(errors, []); + const byId = new Map([...existing, ...elements].map((e) => [e.id, e])); + const taken = existing.map((e) => elementBounds(e, byId)); + for (const node of elements.filter((e) => e.type === "rect")) { + for (const [i, box] of taken.entries()) { + assert.ok(!boxesIntersect(node, box), `${node.id} overlaps ${existing[i].id}`); + } + } +}); + +test("when every spot near a shape is taken, a new one goes past everything, in line with it", () => { + const anchor = { x: 0, y: 0, w: 100, h: 60 }; + // Shapes on all four sides of the anchor cover every spot that placement tries. + const crowd = [ + { x: 120, y: -2000, w: 3000, h: 4000 }, + { x: -3020, y: -2000, w: 3000, h: 4000 }, + { x: -2000, y: 80, w: 4000, h: 3000 }, + { x: -2000, y: -3020, w: 4000, h: 3000 }, + ]; + for (const dir of ["right", "left", "down", "up"]) { + const spot = findFreeSpot(anchor, dir, 100, 60, [anchor, ...crowd]); + const box = { ...spot, w: 100, h: 60 }; + for (const c of crowd) assert.ok(!boxesIntersect(box, c), `the spot to the ${dir} is on top of a shape`); + assert.equal(dir === "right" || dir === "left" ? spot.y : spot.x, 0, `the spot to the ${dir} is not in line with the shape`); + } + + // add_elements places a new connected node the same way. + const existing = [ + normalizeElement({ id: "a", type: "rect", ...anchor, text: "A" }), + ...crowd.map((c, i) => normalizeElement({ id: `c${i}`, type: "rect", ...c })), + ]; + const { elements, errors } = buildFromSpec({ nodes: [{ id: "b", label: "B" }], edges: [{ from: "a", to: "b" }] }, { existing }); + assert.deepEqual(errors, []); + const b = elements.find((e) => e.id === "b"); + for (const e of existing) assert.ok(!boxesIntersect(b, e), `b overlaps ${e.id}`); +}); + +test("crossings are counted the same as by comparing every pair of edges", () => { + // a-d and b-c cross. a-c and b-c only share an end, which is not a crossing. + assert.equal(countCrossings(["a", "b"], { a: ["d", "c"], b: ["c"] }, { c: 0, d: 1 }, 2), 1); + + let seed = 1; + const rand = (n) => { + seed = (seed * 1103515245 + 12345) % 2147483648; + return seed % n; + }; + const shuffled = (list) => { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = rand(i + 1); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; + }; + for (let round = 0; round < 300; round++) { + // Upper vertices are 0 to upperSize - 1 and lower ones come after, each layer in a shuffled + // order. Every upper vertex links to a random set of lower ones, listed in any order. + const upperSize = 1 + rand(10); + const lowerSize = 1 + rand(10); + const upper = shuffled([...Array(upperSize).keys()]); + const lower = shuffled(Array.from({ length: lowerSize }, (_, i) => upperSize + i)); + const pos = {}; + lower.forEach((v, i) => { pos[v] = i; }); + const down = {}; + for (const u of upper) down[u] = shuffled(lower).slice(0, rand(lowerSize + 1)); + + const edges = upper.flatMap((u, i) => down[u].map((v) => [i, pos[v]])); + let expected = 0; + for (let i = 0; i < edges.length; i++) { + for (let j = i + 1; j < edges.length; j++) { + if ((edges[i][0] - edges[j][0]) * (edges[i][1] - edges[j][1]) < 0) expected++; + } + } + assert.equal(countCrossings(upper, down, pos, lowerSize), expected, JSON.stringify({ upper, down, pos })); + } +}); + +test("a long outline comes in parts that stay in budget and list each element once", () => { + const long = "word ".repeat(800); + const elements = [ + ...Array.from({ length: 300 }, (_, i) => shape(`s${i}`, i % 3 ? `Step ${i}` : long, i * 200)), + ...Array.from({ length: 299 }, (_, i) => normalizeElement({ id: `a${i}`, type: "arrow", from: `s${i}`, to: `s${i + 1}` })), + normalizeElement({ id: "note", type: "text", x: 0, y: -40, text: long }), + ]; + const doc = { name: "Big", elements }; + const seen = []; + for (let start = 0; start !== null; ) { + const part = outlinePage(doc, { start, budget: 5000 }); + assert.ok(part.text.length <= 5000, `a part has ${part.text.length} characters`); + assert.ok(part.text.startsWith('Drawing "Big": 300 shapes, 299 arrows, 1 text, 0 pen strokes.\n')); + seen.push(...part.shown.map((e) => e.id)); + start = part.next; + } + assert.deepEqual(seen, elements.map((e) => e.id)); + + const first = outlinePage(doc).text; + assert.ok(!first.includes(long.trim())); + assert.ok(first.includes(`- s0 (rect) "${long.slice(0, 300)}..." (cut from 4000 characters) at 0,0 size`)); + assert.ok(first.includes('- a0: s0 -> s1\n')); + + const some = outlinePage(doc, { only: new Set(["note", "s5"]) }); + assert.deepEqual(some.shown.map((e) => e.id), ["s5", "note"]); + assert.equal(some.total, 2); + assert.equal(some.next, null); + + // What a caller sends along counts too, but a part never comes back empty. + const heavy = outlinePage(doc, { budget: 100, cost: () => 10000 }); + assert.deepEqual(heavy.shown.map((e) => e.id), ["s0"]); + assert.equal(heavy.next, 1); +}); + +test("the outline lists every style that is not the default", () => { + const doc = { + name: "Styles", + elements: [ + normalizeElement({ id: "a", type: "rect", x: 0, y: 0, text: "A", color: "blue", fill: "solid", dash: true, size: "xl" }), + normalizeElement({ id: "b", type: "ellipse", x: 300, y: 0, text: "B" }), + normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b", text: "calls", head: "both", route: "elbow", dash: true, color: "red", size: "l" }), + normalizeElement({ id: "ba", type: "arrow", from: "b", to: "a" }), + normalizeElement({ id: "t1", type: "text", x: 0, y: 200, text: "Note", color: "green", size: "s" }), + normalizeElement({ id: "t2", type: "text", x: 0, y: 300, text: "Title", size: "l" }), + ], + }; + const lines = outlinePage(doc).text.split("\n"); + for (const line of [ + '- a (rect, blue, fill solid, dashed, text size xl) "A" at 0,0 size 160x60', + '- b (ellipse) "B" at 300,0 size 160x80', + '- ab: a -> b "calls" (head both, elbow, dashed, red, text size l)', + "- ba: b -> a", + '- t1 (green, text size s): "Note" at 0,200', + '- t2: "Title" at 0,300', + ]) assert.ok(lines.includes(line), `no line ${line} in:\n${lines.join("\n")}`); +}); + +test("a layout counts a placeholder for each layer an arrow passes, and turns down too many", () => { + // The chain puts a, b, c and d on layers 0 to 3, so an arrow back from d to a passes 2 layers, + // and one from a to c passes 1. The loose node e has no arrows and needs none. + const nodes = ["a", "b", "c", "d", "e"].map((id) => ({ id, w: 100, h: 60 })); + const chain = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "c", to: "d" }]; + const back = [...chain, { from: "d", to: "a" }]; + assert.equal(layeredLayout(nodes, back, { maxPlaceholders: 2 }).size, 5); + assert.throws(() => layeredLayout(nodes, back, { maxPlaceholders: 1 }), (err) => { + assert.ok(err instanceof LayoutError); + assert.match(err.message, /this diagram needs 2, more than the 1 automatic layout handles\./); + return true; + }); + assert.equal(layeredLayout(nodes, [...back, { from: "a", to: "c" }], { maxPlaceholders: 3 }).size, 5); + assert.throws(() => layeredLayout(nodes, [...back, { from: "a", to: "c" }], { maxPlaceholders: 2 }), /needs 3, more than the 2/); + assert.equal(layeredLayout(nodes, chain, { maxPlaceholders: 0 }).size, 5); +}); diff --git a/extensions/draw/lib/model.mjs b/extensions/draw/lib/model.mjs new file mode 100644 index 000000000..cb9359e34 --- /dev/null +++ b/extensions/draw/lib/model.mjs @@ -0,0 +1,369 @@ +// Drawing data model shared by the extension (Node) and the editor (browser). +// Pure functions only: no DOM and no file system. + +export const GRID = 10; +export const SHAPE_TYPES = ["rect", "ellipse", "diamond", "cylinder"]; +export const ELEMENT_TYPES = [...SHAPE_TYPES, "text", "arrow", "pen"]; +export const COLORS = ["gray", "blue", "green", "yellow", "orange", "red", "purple", "pink"]; +export const FILLS = ["none", "soft", "solid"]; +export const HEADS = ["end", "start", "both", "none"]; +export const ROUTES = ["straight", "elbow", "curve"]; +export const FONT_SIZES = { s: 13, m: 15, l: 20, xl: 28 }; +export const SIZE_KEYS = Object.keys(FONT_SIZES); +export const PEN_WIDTHS = { s: 1.5, m: 2.5, l: 4, xl: 6 }; +export const DEFAULT_SIZES = { rect: [160, 60], ellipse: [160, 80], diamond: [180, 120], cylinder: [120, 80] }; +export const LABEL_WEIGHT = 500; +export const TEXT_WEIGHT = 400; +export const MAX_ELEMENTS = 5000; +// The biggest width or height a shape can have. +export const MAX_SIDE = 5000; +// The most points a pen stroke keeps. Low enough that even the biggest stroke still fits in the +// 64 KiB a page can send as it closes (see flushBeacon in public/sync.js). +export const MAX_PEN_POINTS = 2000; +// How far from 0 a position can be. One further out is moved back to it. +export const MAX_COORD = 1e6; + +const ID_PREFIX = { rect: "r", ellipse: "o", diamond: "d", cylinder: "c", text: "t", arrow: "a", pen: "p" }; +export const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$/; + +export const isNum = (v) => typeof v === "number" && Number.isFinite(v); +export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v)); +export const round2 = (v) => Math.round(v * 100) / 100; +export const snap = (v, grid = GRID) => Math.round(v / grid) * grid; +export const isShape = (el) => !!el && SHAPE_TYPES.includes(el.type); +const lower = (v) => (typeof v === "string" ? v.trim().toLowerCase() : ""); + +function toNum(v, fallback) { + if (isNum(v)) return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return fallback; +} +const coord = (v) => round2(clamp(toNum(v, 0), -MAX_COORD, MAX_COORD)); + +export function newId(prefix = "e") { + const chars = "abcdefghijkmnpqrstuvwxyz23456789"; + let s = prefix; + for (let i = 0; i < 7; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +export function uniqueId(prefix, taken) { + let id = newId(prefix); + while (taken.has(id)) id = newId(prefix); + taken.add(id); + return id; +} + +export function idPrefix(type) { + return ID_PREFIX[type] || "e"; +} + +const TYPE_ALIASES = { + rectangle: "rect", box: "rect", square: "rect", process: "rect", node: "rect", shape: "rect", card: "rect", step: "rect", + circle: "ellipse", oval: "ellipse", terminal: "ellipse", pill: "ellipse", + decision: "diamond", rhombus: "diamond", condition: "diamond", choice: "diamond", + database: "cylinder", db: "cylinder", storage: "cylinder", datastore: "cylinder", + label: "text", note: "text", title: "text", + line: "arrow", edge: "arrow", connector: "arrow", link: "arrow", + freehand: "pen", stroke: "pen", sketch: "pen", +}; + +export function resolveType(raw) { + let t = lower(raw?.type); + if ((!t || t === "shape" || t === "node") && raw?.shape) t = lower(raw.shape); + t = TYPE_ALIASES[t] || t; + return ELEMENT_TYPES.includes(t) ? t : null; +} + +export function resolveShapeType(value, fallback = "rect") { + const t = TYPE_ALIASES[lower(value)] || lower(value); + return SHAPE_TYPES.includes(t) ? t : fallback; +} + +const COLOR_ALIASES = { + grey: "gray", black: "gray", white: "gray", default: "gray", neutral: "gray", none: "gray", + cyan: "blue", navy: "blue", sky: "blue", teal: "green", lime: "green", emerald: "green", + amber: "yellow", gold: "yellow", violet: "purple", indigo: "purple", lavender: "purple", + magenta: "pink", rose: "pink", crimson: "red", brown: "orange", +}; + +export function normalizeColor(value, fallback = "gray") { + const s = lower(value); + if (!s) return fallback; + if (COLORS.includes(s)) return s; + if (COLOR_ALIASES[s]) return COLOR_ALIASES[s]; + if (/^#[0-9a-f]{6}$/.test(s)) return s; + if (/^#[0-9a-f]{3}$/.test(s)) return "#" + [...s.slice(1)].map((c) => c + c).join(""); + return fallback; +} + +export function normalizeFill(value, fallback = "soft") { + if (value === true) return "soft"; + if (value === false) return "none"; + const s = lower(value); + if (FILLS.includes(s)) return s; + const alias = { + outline: "none", transparent: "none", empty: "none", hollow: "none", + light: "soft", tint: "soft", tinted: "soft", pastel: "soft", + filled: "solid", full: "solid", strong: "solid", bold: "solid", dark: "solid", + }; + return alias[s] || fallback; +} + +export function normalizeHead(value, fallback = "end") { + if (value === false) return "none"; + if (value === true) return "end"; + const s = lower(value); + if (HEADS.includes(s)) return s; + const alias = { + arrow: "end", forward: "end", to: "end", back: "start", backward: "start", reverse: "start", from: "start", + "two-way": "both", bidirectional: "both", double: "both", line: "none", no: "none", off: "none", plain: "none", + }; + return alias[s] || fallback; +} + +export function normalizeRoute(value, fallback = "straight") { + const s = lower(value); + if (ROUTES.includes(s)) return s; + const alias = { + direct: "straight", line: "straight", linear: "straight", + orthogonal: "elbow", step: "elbow", elbowed: "elbow", manhattan: "elbow", angled: "elbow", + curved: "curve", bezier: "curve", spline: "curve", smooth: "curve", arc: "curve", + }; + return alias[s] || fallback; +} + +export function normalizeSize(value, fallback = "m") { + if (isNum(value)) { + let best = fallback; + let diff = Infinity; + for (const k of SIZE_KEYS) { + const d = Math.abs(FONT_SIZES[k] - value); + if (d < diff) { diff = d; best = k; } + } + return best; + } + const s = lower(value); + if (SIZE_KEYS.includes(s)) return s; + const alias = { small: "s", sm: "s", medium: "m", md: "m", normal: "m", large: "l", lg: "l", big: "l", xlarge: "xl", "x-large": "xl", huge: "xl" }; + return alias[s] || fallback; +} + +export function normalizeText(value) { + if (typeof value === "number") return String(value); + if (typeof value !== "string") return ""; + // eslint-disable-next-line no-control-regex + return value.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").slice(0, 4000); +} + +function refId(value) { + return typeof value === "string" && ID_PATTERN.test(value) ? value : null; +} + +// A stroke with more than `max` points keeps an even spread of them, first and last included, so +// it loses detail rather than its end. +export function limitPoints(points, max = MAX_PEN_POINTS) { + if (points.length <= max) return points; + const step = (points.length - 1) / (max - 1); + return Array.from({ length: max }, (_, i) => points[Math.round(i * step)]); +} + +function normalizePoints(points) { + if (!Array.isArray(points)) return []; + const out = []; + for (const p of limitPoints(points)) { + const nx = toNum(Array.isArray(p) ? p[0] : p?.x, NaN); + const ny = toNum(Array.isArray(p) ? p[1] : p?.y, NaN); + if (Number.isFinite(nx) && Number.isFinite(ny)) { + out.push([Math.round(clamp(nx, -MAX_COORD, MAX_COORD) * 10) / 10, Math.round(clamp(ny, -MAX_COORD, MAX_COORD) * 10) / 10]); + } + } + return out; +} + +// Turns loose input (from the editor, a file or the agent) into a clean element, or null. +export function normalizeElement(raw) { + if (!raw || typeof raw !== "object") return null; + const type = resolveType(raw); + if (!type) return null; + const id = typeof raw.id === "string" && ID_PATTERN.test(raw.id) ? raw.id : newId(ID_PREFIX[type]); + const color = normalizeColor(raw.color); + const dash = Boolean(raw.dash ?? raw.dashed); + if (isShape({ type })) { + const [dw, dh] = DEFAULT_SIZES[type]; + return { + id, type, + x: coord(raw.x), y: coord(raw.y), + w: round2(clamp(toNum(raw.w ?? raw.width, dw), 10, MAX_SIDE)), + h: round2(clamp(toNum(raw.h ?? raw.height, dh), 10, MAX_SIDE)), + text: normalizeText(raw.text ?? raw.label), + color, fill: normalizeFill(raw.fill), dash, size: normalizeSize(raw.size ?? raw.fontSize), + }; + } + if (type === "text") { + return { id, type, x: coord(raw.x), y: coord(raw.y), text: normalizeText(raw.text ?? raw.label), color, size: normalizeSize(raw.size ?? raw.fontSize) }; + } + if (type === "arrow") { + return { + id, type, + from: refId(raw.from ?? raw.source), to: refId(raw.to ?? raw.target), + x1: coord(raw.x1), y1: coord(raw.y1), x2: coord(raw.x2), y2: coord(raw.y2), + text: normalizeText(raw.text ?? raw.label), + color, dash, + head: normalizeHead(raw.head, lower(raw.type) === "line" ? "none" : "end"), + route: normalizeRoute(raw.route), + size: normalizeSize(raw.size ?? raw.fontSize, "s"), + }; + } + const points = normalizePoints(raw.points); + if (!points.length) return null; + return { id, type: "pen", points, color, width: round2(clamp(toNum(raw.width ?? raw.strokeWidth, PEN_WIDTHS.m), 0.5, 24)) }; +} + +// Normalizes a whole list: fixes duplicate ids and drops arrows that point at missing shapes. +// Anything past `limit` elements is dropped. +export function normalizeElements(list, limit = MAX_ELEMENTS) { + const out = []; + const taken = new Set(); + for (const raw of Array.isArray(list) ? list.slice(0, limit) : []) { + const el = normalizeElement(raw); + if (!el) continue; + if (taken.has(el.id)) el.id = uniqueId(ID_PREFIX[el.type], taken); + taken.add(el.id); + out.push(el); + } + const shapes = new Set(out.filter(isShape).map((e) => e.id)); + return out.filter((el) => { + if (el.type !== "arrow") return true; + if (el.from && !shapes.has(el.from)) return false; + if (el.to && !shapes.has(el.to)) return false; + if (el.from && el.from === el.to) return false; + if (!el.from && !el.to && Math.hypot(el.x2 - el.x1, el.y2 - el.y1) < 1) return false; + return true; + }); +} + +// Merges element-level changes: { upserts: [element], deletes: [id], order?: [id] }. +export function applyOps(elements, ops = {}, limit = MAX_ELEMENTS) { + return normalizeElements(mergeOps(elements, ops), limit); +} + +// The merge step of applyOps, before the list is cleaned up. +export function mergeOps(elements, ops = {}) { + const map = new Map(elements.map((e) => [e.id, e])); + for (const id of Array.isArray(ops.deletes) ? ops.deletes : []) map.delete(id); + for (const raw of Array.isArray(ops.upserts) ? ops.upserts : []) { + const el = normalizeElement(raw); + if (el) map.set(el.id, el); + } + let list = [...map.values()]; + if (Array.isArray(ops.order) && ops.order.length) { + const rank = new Map(ops.order.map((id, i) => [id, i])); + const known = list.filter((e) => rank.has(e.id)).sort((a, b) => rank.get(a.id) - rank.get(b.id)); + list = [...known, ...list.filter((e) => !rank.has(e.id))]; + } + return list; +} + +// Whether two JSON values hold the same data, whatever order their keys are in. +export function sameJson(a, b) { + if (a === b) return true; + if (!a || !b || typeof a !== "object" || typeof b !== "object") return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (!sameJson(a[i], b[i])) return false; + return true; + } + const keys = Object.keys(a); + if (keys.length !== Object.keys(b).length) return false; + for (const k of keys) if (!Object.hasOwn(b, k) || !sameJson(a[k], b[k])) return false; + return true; +} + +// The ops that turn the elements in `before` (a Map by id) into `elements`, as mergeOps applies +// them, or null when nothing changed. +export function diffOps(before, elements) { + const upserts = []; + const ids = new Set(); + for (const el of elements) { + ids.add(el.id); + const prev = before.get(el.id); + if (!prev || !sameJson(prev, el)) upserts.push(el); + } + const deletes = [...before.keys()].filter((id) => !ids.has(id)); + // mergeOps keeps existing ids in place and appends new ones, so order is only sent when that differs. + const mergedOrder = [...before.keys()].filter((id) => ids.has(id)); + for (const el of elements) if (!before.has(el.id)) mergedOrder.push(el.id); + const orderChanged = mergedOrder.some((id, i) => id !== elements[i].id); + if (!upserts.length && !deletes.length && !orderChanged) return null; + const ops = { upserts, deletes }; + if (orderChanged) ops.order = elements.map((e) => e.id); + return ops; +} + +// Removes elements plus any arrows attached to them. +export function removeWithArrows(elements, ids) { + const del = new Set(ids); + for (const el of elements) if (el.type === "arrow" && (del.has(el.from) || del.has(el.to))) del.add(el.id); + return { + elements: elements.filter((e) => !del.has(e.id)), + removed: elements.filter((e) => del.has(e.id)).map((e) => e.id), + }; +} + +// ---------- colors ---------- + +export const LIGHT_PALETTE = { + bg: "#ffffff", ink: "#1f2328", + blue: "#0969da", green: "#1a7f37", yellow: "#9a6700", orange: "#bc4c00", + red: "#cf222e", purple: "#8250df", pink: "#bf3989", +}; +export const DARK_PALETTE = { + bg: "#0d1117", ink: "#e6edf3", + blue: "#4493f8", green: "#3fb950", yellow: "#d29922", orange: "#db6d28", + red: "#f85149", purple: "#ab7df8", pink: "#db61a2", +}; + +function hexToRgb(hex) { + const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim()); + if (!m) return [0, 0, 0]; + const n = parseInt(m[1], 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} +const toHex = (rgb) => "#" + rgb.map((v) => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, "0")).join(""); + +export function mixHex(a, b, t) { + const ca = hexToRgb(a); + const cb = hexToRgb(b); + return toHex(ca.map((v, i) => v * t + cb[i] * (1 - t))); +} + +export function luminance(hex) { + const [r, g, b] = hexToRgb(hex).map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +// Text color for a solid background. Dark and white text have equal contrast at about 0.21. +export function textOn(hex) { + return luminance(hex) > 0.21 ? "#1f2328" : "#ffffff"; +} + +// Returns paint(color, role) resolving element colors to hex for a palette. +// Roles: stroke, soft (tinted fill), solid (strong fill), onSolid (text on solid), ink (text), bg. +export function staticPaint(palette = LIGHT_PALETTE) { + const pal = { ...LIGHT_PALETTE, ...palette }; + const dark = luminance(pal.bg) < 0.4; + const base = (color) => (color === "gray" ? pal.ink : String(color).startsWith("#") ? color : pal[color] || pal.ink); + return (color, role) => { + const c = base(color); + switch (role) { + case "soft": return mixHex(c, pal.bg, color === "gray" ? (dark ? 0.12 : 0.06) : (dark ? 0.24 : 0.14)); + case "onSolid": return textOn(c); + case "bg": return pal.bg; + default: return c; + } + }; +} diff --git a/extensions/draw/lib/model.test.mjs b/extensions/draw/lib/model.test.mjs new file mode 100644 index 000000000..04b076f0f --- /dev/null +++ b/extensions/draw/lib/model.test.mjs @@ -0,0 +1,95 @@ +// Tests for the element model: cleaning up loose input and picking readable text colors. +// Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import test from "node:test"; +import { COLORS, DARK_PALETTE, LIGHT_PALETTE, MAX_PEN_POINTS, diffOps, luminance, mergeOps, normalizeElement, normalizeElements, sameJson, staticPaint } from "./model.mjs"; + +const contrast = (a, b) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +}; + +test("text on every solid color is readable in both themes", () => { + for (const [theme, palette] of [["light", LIGHT_PALETTE], ["dark", DARK_PALETTE]]) { + const paint = staticPaint(palette); + for (const color of COLORS) { + const ratio = contrast(paint(color, "onSolid"), paint(color, "solid")); + assert.ok(ratio >= 4.5, `${theme} ${color} is only ${ratio.toFixed(2)}:1`); + } + } +}); + +test("loose input is cleaned up, and arrows to missing shapes are dropped", () => { + const out = normalizeElements([ + { id: "a", type: "rect" }, + { id: "a", type: "circle", x: 200 }, + { type: "arrow", from: "a", to: "missing" }, + { type: "spaceship" }, + null, + "rect", + ]); + assert.deepEqual(out.map((e) => e.type), ["rect", "ellipse"]); + assert.equal(out[0].id, "a"); + assert.notEqual(out[1].id, "a"); +}); + +test("the ops from diffOps turn one list into the other", () => { + // A small random generator (MINSTD), so a failure can be run again. + let seed = 7; + const rand = (n) => { + seed = (seed * 48271) % 2147483647; + return seed % n; + }; + const shape = (id) => normalizeElement({ id, type: "rect", x: rand(5) * 10, text: rand(2) ? "a" : "" }); + for (let round = 0; round < 500; round++) { + const before = Array.from({ length: rand(8) }, (_, i) => shape(`e${i}`)); + let after = before.filter(() => rand(4)).map((e) => (rand(3) ? e : shape(e.id))); + after.push(...Array.from({ length: rand(3) }, (_, i) => shape(`n${i}`))); + if (rand(2)) after = after.sort(() => rand(3) - 1); + const ops = diffOps(new Map(before.map((e) => [e.id, e])), after); + if (!ops) { + assert.deepEqual(after, before); + continue; + } + assert.deepEqual(mergeOps(before, ops), after); + // Only elements that changed are sent. + for (const el of ops.upserts) assert.ok(!sameJson(el, before.find((e) => e.id === el.id))); + } +}); + +test("sameJson compares data, not key order", () => { + assert.ok(sameJson({ a: 1, b: [1, { c: 2 }] }, { b: [1, { c: 2 }], a: 1 })); + assert.ok(!sameJson({ a: 1 }, { a: 1, b: 2 })); + assert.ok(!sameJson({ a: 1, c: 2 }, { a: 1, b: 2 })); + assert.ok(!sameJson([1, 2], { 0: 1, 1: 2 })); + assert.ok(!sameJson([[1, 2]], [[1, 3]])); + assert.ok(!sameJson(null, {})); +}); + +test("a stroke with too many points keeps both its ends", () => { + const points = Array.from({ length: 3 * MAX_PEN_POINTS }, (_, i) => [i, i % 7]); + const pen = normalizeElement({ type: "pen", points }); + assert.equal(pen.points.length, MAX_PEN_POINTS); + assert.deepEqual(pen.points[0], points[0]); + assert.deepEqual(pen.points.at(-1), points.at(-1)); + // Spread evenly, and in order. + assert.ok(pen.points.every((p, i) => i === 0 || p[0] > pen.points[i - 1][0])); + assert.equal(normalizeElement({ type: "pen", points: points.slice(0, 10) }).points.length, 10); +}); + +test("any one element, however big, fits in what a page can send as it closes", () => { + // Browsers only deliver keepalive requests after the page is gone, and refuse them past 64 KiB + // in all (see flushBeacon in public/sync.js). + const far = -999999.94; + const pen = normalizeElement({ id: "p".repeat(64), type: "pen", color: "#123456", points: Array.from({ length: 3 * MAX_PEN_POINTS }, () => [far, far]) }); + assert.equal(pen.points.length, MAX_PEN_POINTS); + assert.deepEqual(pen.points[0], [-999999.9, -999999.9]); + // A lone surrogate is written as a 6 byte escape, the most any one character of a label takes. + const text = "\ud800".repeat(10000); + const labelled = ["rect", "ellipse", "diamond", "cylinder", "text", "arrow"].map((type) => + normalizeElement({ id: type[0].repeat(64), type, text, from: "f".repeat(64), to: "g".repeat(64) })); + for (const el of [pen, ...labelled]) { + const body = JSON.stringify({ clientId: "c".repeat(64), drawingId: "d".repeat(64), seq: Number.MAX_SAFE_INTEGER, ops: { upserts: [el], deletes: [] } }); + assert.ok(Buffer.byteLength(body) <= 64 * 1024, `${el.type}: ${Buffer.byteLength(body)} bytes`); + } +}); diff --git a/extensions/draw/lib/render.mjs b/extensions/draw/lib/render.mjs new file mode 100644 index 000000000..0e72215e1 --- /dev/null +++ b/extensions/draw/lib/render.mjs @@ -0,0 +1,129 @@ +// Renders drawing elements to SVG markup. Used for the live editor and for exports. +import { FONT_SIZES, LABEL_WEIGHT, TEXT_WEIGHT, isShape, staticPaint } from "./model.mjs"; +import { + HEAD_LENGTH, HEAD_HALF_WIDTH, fmt, lineHeight, baselineOffset, approxMeasure, cylinderCap, + shapeLabelLayout, textLayout, arrowGeometry, arrowLabelBox, contentBounds, +} from "./geometry.mjs"; + +export function escapeXml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +const DASH = ' stroke-dasharray="6 6"'; + +export function outline(s, attrs) { + const { x, y, w, h } = s; + if (s.type === "ellipse") { + return ``; + } + if (s.type === "diamond") { + return ``; + } + if (s.type === "cylinder") { + const c = cylinderCap(s); + const rx = fmt(w / 2); + const body = `M${fmt(x)} ${fmt(y + c)} L${fmt(x)} ${fmt(y + h - c)} A${rx} ${fmt(c)} 0 0 0 ${fmt(x + w)} ${fmt(y + h - c)} L${fmt(x + w)} ${fmt(y + c)} A${rx} ${fmt(c)} 0 0 0 ${fmt(x)} ${fmt(y + c)} Z`; + return ``; + } + const r = Math.min(8, w / 4, h / 4); + return ``; +} + +function textLines(lines, x, top, fs, lh, weight, fill, anchor) { + const base = baselineOffset(fs, lh); + let out = ``; + lines.forEach((line, i) => { + out += `${escapeXml(line) || " "}`; + }); + return `${out}`; +} + +function renderShape(s, ctx) { + const stroke = ctx.paint(s.color, "stroke"); + const fill = s.fill === "solid" ? ctx.paint(s.color, "solid") : s.fill === "soft" ? ctx.paint(s.color, "soft") : "none"; + const attrs = ` fill="${fill}" stroke="${stroke}" stroke-width="2" stroke-linejoin="round"${s.dash ? DASH : ""}`; + let out = outline(s, attrs); + if (s.text && ctx.hideText !== s.id) { + const l = shapeLabelLayout(s, ctx.measure); + const ink = s.fill === "solid" ? ctx.paint(s.color, "onSolid") : ctx.paint("gray", "ink"); + out += textLines(l.lines, l.cx, l.top, l.fs, l.lh, LABEL_WEIGHT, ink, "middle"); + } + return `${out}`; +} + +function renderText(t, ctx) { + if (ctx.hideText === t.id || !t.text) return ""; + const l = textLayout(t, ctx.measure); + return `${textLines(l.lines, t.x, t.y, l.fs, l.lh, TEXT_WEIGHT, ctx.paint(t.color, "ink"), "start")}`; +} + +export function penPath(points) { + const p = points; + if (p.length === 1) return `M${fmt(p[0][0])} ${fmt(p[0][1])} l0.01 0`; + if (p.length === 2) return `M${fmt(p[0][0])} ${fmt(p[0][1])} L${fmt(p[1][0])} ${fmt(p[1][1])}`; + let d = `M${fmt(p[0][0])} ${fmt(p[0][1])}`; + for (let i = 1; i < p.length - 1; i++) { + const mx = (p[i][0] + p[i + 1][0]) / 2; + const my = (p[i][1] + p[i + 1][1]) / 2; + d += ` Q${fmt(p[i][0])} ${fmt(p[i][1])} ${fmt(mx)} ${fmt(my)}`; + } + const last = p[p.length - 1]; + return `${d} L${fmt(last[0])} ${fmt(last[1])}`; +} + +function renderPen(el, ctx) { + return ``; +} + +function headPath(tip, dir) { + const bx = tip.x - dir.x * HEAD_LENGTH; + const by = tip.y - dir.y * HEAD_LENGTH; + const px = -dir.y * HEAD_HALF_WIDTH; + const py = dir.x * HEAD_HALF_WIDTH; + return `M${fmt(tip.x)} ${fmt(tip.y)} L${fmt(bx + px)} ${fmt(by + py)} L${fmt(bx - px)} ${fmt(by - py)} Z`; +} + +function renderArrow(a, byId, ctx) { + const g = arrowGeometry(a, byId); + if (!g) return ""; + const stroke = ctx.paint(a.color, "stroke"); + let out = ``; + const head = (tip, dir) => ``; + if (a.head === "end" || a.head === "both") out += head(g.end, g.endDir); + if (a.head === "start" || a.head === "both") out += head(g.start, g.startDir); + if (a.text && ctx.hideText !== a.id) { + const box = arrowLabelBox(a, g, ctx.measure); + out += ``; + out += textLines(box.lines, g.mid.x, box.y + 2, box.fs, box.lh, TEXT_WEIGHT, ctx.paint(a.color, "ink"), "middle"); + } + return `${out}`; +} + +// Returns SVG markup (no wrapper). Arrows are drawn last so they sit on top. +export function renderElements(elements, { measure = approxMeasure, paint = staticPaint(), hideText = null } = {}) { + const byId = new Map(elements.map((e) => [e.id, e])); + const ctx = { measure, paint, hideText }; + const parts = []; + for (const el of elements) { + if (isShape(el)) parts.push(renderShape(el, ctx)); + else if (el.type === "text") parts.push(renderText(el, ctx)); + else if (el.type === "pen") parts.push(renderPen(el, ctx)); + } + for (const el of elements) if (el.type === "arrow") parts.push(renderArrow(el, byId, ctx)); + return parts.join(""); +} + +export const DEFAULT_FONT = "'Segoe UI', system-ui, -apple-system, 'Helvetica Neue', Arial, sans-serif"; + +// A complete SVG document sized to the content. +export function renderStandaloneSVG(elements, { measure = approxMeasure, paint = staticPaint(), fontFamily = DEFAULT_FONT, padding = 32, background = null } = {}) { + const b = contentBounds(elements, measure) || { x: 0, y: 0, w: 240, h: 120 }; + const x = Math.floor(b.x - padding); + const y = Math.floor(b.y - padding); + const w = Math.ceil(b.w + 2 * padding); + const h = Math.ceil(b.h + 2 * padding); + const bg = background ? `` : ""; + return `${bg}${renderElements(elements, { measure, paint })}`; +} + +export { FONT_SIZES, lineHeight }; diff --git a/extensions/draw/public/announce.js b/extensions/draw/public/announce.js new file mode 100644 index 000000000..ed71f7405 --- /dev/null +++ b/extensions/draw/public/announce.js @@ -0,0 +1,47 @@ +// Words for screen readers: what an element is, and a live region that reads out what the +// keyboard just did on the canvas. +export const TYPE_NAMES = { + rect: "Rectangle", + ellipse: "Ellipse", + diamond: "Diamond", + cylinder: "Database", + text: "Text", + arrow: "Arrow", + pen: "Pen stroke", +}; + +const words = (s) => String(s || "").replace(/\s+/g, " ").trim(); +const quote = (s) => { + const chars = Array.from(words(s)); + return `"${chars.length > 60 ? `${chars.slice(0, 57).join("")}...` : chars.join("")}"`; +}; + +// A shape by its label, or by its kind when it has none: "Login", or "a rectangle". +function shapeName(el) { + if (words(el.text)) return quote(el.text); + const kind = (TYPE_NAMES[el.type] || "shape").toLowerCase(); + return `${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}`; +} + +// For example: Rectangle "Login", or Arrow from "Login" to a database, labeled "reads". +export function describeElement(el, byId) { + if (el.type === "pen") return TYPE_NAMES.pen; + if (el.type === "arrow") { + const from = el.from ? byId.get(el.from) : null; + const to = el.to ? byId.get(el.to) : null; + const ends = `${from ? ` from ${shapeName(from)}` : ""}${to ? ` to ${shapeName(to)}` : ""}`; + return `Arrow${ends}${words(el.text) ? `, labeled ${quote(el.text)}` : ""}`; + } + const type = TYPE_NAMES[el.type] || "Element"; + return words(el.text) ? `${type} ${quote(el.text)}` : `${type} with no label`; +} + +// Screen readers skip a message that matches the one before it, so every other message ends in a +// space that is not shown. +export function makeAnnouncer(node) { + let odd = false; + return (message) => { + odd = !odd; + node.textContent = odd ? message : `${message}\u00a0`; + }; +} diff --git a/extensions/draw/public/app.js b/extensions/draw/public/app.js new file mode 100644 index 000000000..6ba967a34 --- /dev/null +++ b/extensions/draw/public/app.js @@ -0,0 +1,138 @@ +// Boots the Draw canvas: theme, editor, input, UI and sync with the extension. +import { Editor } from "./editor.js"; +import { LabelEditor } from "./labels.js"; +import { attachPointer } from "./pointer.js"; +import { attachKeys } from "./keys.js"; +import { UI } from "./ui.js"; +import { attachMenus } from "./menus.js"; +import { Sync } from "./sync.js"; +import { blobToBase64 } from "./util.js"; +import { readTheme, applyThemeVars, watchTheme, setMeasureFont, clearMeasureCache, measure, THEME_MODES } from "./theme.js"; + +const $ = (id) => document.getElementById(id); +const params = new URLSearchParams(location.search); +const instanceId = params.get("i") || ""; +const token = params.get("t") || ""; + +function loadTheme(mode) { + const t = readTheme(mode); + setMeasureFont(t.font); + applyThemeVars($("app"), t); + return { ...t, measure }; +} + +// The page stays invisible until the host's theme is in, or briefly when there is no host. +const reveal = () => $("app").classList.remove("booting"); + +async function boot() { + // The server writes the saved theme choice into the page, so the first paint is already right. + let mode = THEME_MODES.includes($("app").dataset.themeMode) ? $("app").dataset.themeMode : "app"; + let theme = loadTheme(mode); + if (theme.present.length) reveal(); + else setTimeout(reveal, 400); + const ed = new Editor( + { stage: $("stage"), svg: $("svg"), world: $("world"), content: $("content"), overlay: $("overlay"), layer: $("layer") }, + theme, + ); + new LabelEditor(ed); + attachPointer(ed); + const ui = new UI(ed); + ui.themeMode = () => mode; + ui.setThemeMode = (next) => applyMode(next, true); + attachMenus(ui); + ui.syncThemeButton(); + attachKeys(ed, ui); + + // Match app, light or dark. Saving the choice also switches every other open panel. + function applyMode(next, save) { + if (!THEME_MODES.includes(next)) return; + if (next !== mode) { + mode = next; + $("app").dataset.themeMode = mode; + theme = loadTheme(mode); + ed.setTheme(theme); + ui.refresh(); + ui.syncThemeButton(); + } + if (save) { + sync.post("settings", { theme: next }).catch((err) => ui.toast(`Could not save the theme: ${err.message}`, { error: true })); + } + } + + // Draws the picture an agent asked for. The reply names the drawing this panel shows, so the + // server can turn it down when that is not the one it asked for. + async function answerExport({ requestId, format, drawingId: wanted }) { + const drawingId = sync.drawingId; + const reply = (fields) => sync.post("export", { requestId, drawingId, format, ...fields }); + try { + if (wanted !== drawingId) return await reply({}); + await reply({ data: format === "svg" ? ed.exportSVG() : await blobToBase64(await ed.exportPNG()) }); + } catch (err) { + reply({ error: err.message || String(err) }).catch(() => {}); + } + } + + const sync = new Sync({ + instanceId, + token, + editor: ed, + handlers: { + connection: (ok) => ui.setConnection(ok), + list: (drawings) => ui.setDrawings(drawings), + switched: (drawing) => { + ui.closePopover(); + ui.cancelRename?.(); + ed.load(drawing); + ui.setDoc(drawing); + ui.syncEmpty(); + }, + exportRequest: answerExport, + select: (ids) => ed.select(ids), + status: (kind, msg) => ui.setStatus(kind, msg), + problem: (msg) => ui.toast(msg, { error: true, timeout: 8000 }), + unsaved: (drawing) => ui.warnUnsaved(() => ui.drawingsAction({ action: "open", id: drawing.id }, { discard: true })), + settings: (values) => applyMode(values.theme, false), + }, + }); + ui.sync = sync; + ed.on("selection", () => sync.sendSelection([...ed.selection])); + + watchTheme(() => { + theme = loadTheme(mode); + ed.setTheme(theme); + ui.refresh(); + if (theme.present.length) reveal(); + }); + document.fonts?.ready.then(() => { + clearMeasureCache(); + ed.refresh(); + }); + + if (!instanceId || !token) { + reveal(); + ui.fatal("This page must be opened from the Draw canvas in Copilot."); + return; + } + let state; + try { + state = await sync.loadState(); + } catch (err) { + reveal(); + ui.fatal(err.message); + return; + } + if (state.settings) applyMode(state.settings.theme, false); + sync.setDoc(state.drawing); + ui.setup(state); + ed.load(state.drawing); + ui.syncEmpty(); + sync.setDiskError(state.drawing.saveError); + sync.connect(); + ed.focusCanvas(); +} + +boot().catch((err) => { + reveal(); + const stage = $("stage"); + if (stage) stage.textContent = `Draw failed to start: ${err.message}`; +}); diff --git a/extensions/draw/public/editor.js b/extensions/draw/public/editor.js new file mode 100644 index 000000000..1c45621c1 --- /dev/null +++ b/extensions/draw/public/editor.js @@ -0,0 +1,751 @@ +// Editor core: elements, selection, view, history, rendering and element-level commands. +import { + isShape, snap, clamp, normalizeElement, normalizeElements, removeWithArrows, newId, idPrefix, sameJson, + PEN_WIDTHS, SHAPE_TYPES, MAX_SIDE, DEFAULT_SIZES, +} from "/lib/model.mjs"; +import { renderElements, renderStandaloneSVG, outline, penPath } from "/lib/render.mjs"; +import { + fmt, arrowGeometry, elementBounds, contentBounds, unionBounds, neededHeight, lineHeight, fontSizeOf, +} from "/lib/geometry.mjs"; +import { findFreeSpot } from "/lib/layout.mjs"; +import { describeElement } from "./announce.js"; + +export const MIN_ZOOM = 0.1; +export const MAX_ZOOM = 4; +const HISTORY_LIMIT = 200; +const round = (v) => Math.round(v * 100) / 100; +// The model keeps pen points to one decimal, so moved points are rounded the same way. +const roundPoint = (v) => Math.round(v * 10) / 10; +export const STYLE_KEYS = { + shape: ["color", "fill", "dash", "size", "type"], + text: ["color", "size"], + arrow: ["color", "dash", "head", "route", "size"], + pen: ["color", "width"], +}; + +export const categoryOf = (el) => (isShape(el) ? "shape" : el.type); +const sameElements = (a, b) => a === b || (a.length === b.length && a.every((e, i) => e === b[i])); + +export function translateElement(el, dx, dy) { + if (el.type === "pen") return { ...el, points: el.points.map(([x, y]) => [roundPoint(x + dx), roundPoint(y + dy)]) }; + if (el.type === "arrow") { + const next = { ...el }; + if (!el.from) { next.x1 = round(el.x1 + dx); next.y1 = round(el.y1 + dy); } + if (!el.to) { next.x2 = round(el.x2 + dx); next.y2 = round(el.y2 + dy); } + return next; + } + return { ...el, x: round(el.x + dx), y: round(el.y + dy) }; +} + +export class Editor { + constructor(dom, theme) { + Object.assign(this, dom); + this.theme = theme; + this.elements = []; + this.byId = new Map(); + this.selection = new Set(); + this.tool = "select"; + this.view = { x: 0, y: 0, zoom: 1 }; + this.history = []; + this.future = []; + this.handlers = new Map(); + this.interaction = null; + this.changeStart = null; + this.editing = null; + this.hover = null; + this.hoverHandle = null; + this.target = null; + // The shape an arrow made from the keyboard starts at, until Enter picks where it ends. + this.linkFrom = null; + this.marquee = null; + this.guides = []; + this.preview = null; + this.spaceDown = false; + this.lastPointer = null; + this.lastDirection = "right"; + this.contentDirty = true; + this.raf = 0; + this.styles = { + shape: { color: "gray", fill: "soft", dash: false, size: "m" }, + text: { color: "gray", size: "l" }, + arrow: { color: "gray", dash: false, head: "end", route: "straight", size: "s" }, + pen: { color: "gray", width: PEN_WIDTHS.m }, + }; + new ResizeObserver(() => { + if (this.needsFit && this.stage.clientWidth && this.stage.clientHeight) { + this.needsFit = false; + this.fit(); + } + this.requestRender(); + }).observe(this.stage); + } + + get measure() { return this.theme.measure; } + get paint() { return this.theme.paint; } + get busy() { return !!(this.interaction || this.editing); } + + on(name, fn) { + if (!this.handlers.has(name)) this.handlers.set(name, new Set()); + this.handlers.get(name).add(fn); + } + + emit(name, data) { + for (const fn of this.handlers.get(name) || []) fn(data); + } + + // ---------- elements and history ---------- + + setElements(next, { record = true, emit = true } = {}) { + if (next === this.elements) return; + if (record && !this.changeStart) this.pushHistory(this.elements); + this.elements = next; + this.byId = new Map(next.map((e) => [e.id, e])); + let pruned = false; + for (const id of this.selection) if (!this.byId.has(id)) { this.selection.delete(id); pruned = true; } + if (this.hover && !this.byId.has(this.hover)) this.hover = null; + if (this.linkFrom && !this.byId.has(this.linkFrom)) this.linkFrom = null; + this.contentDirty = true; + this.requestRender(); + if (pruned) this.emit("selection"); + if (emit) this.emit("change"); + } + + replaceElement(el, opts) { + this.setElements(this.elements.map((e) => (e.id === el.id ? el : e)), opts); + } + + upsertElement(el, opts) { + if (this.byId.has(el.id)) this.replaceElement(el, opts); + else this.setElements([...this.elements, el], opts); + } + + pushHistory(snapshot) { + this.history.push(snapshot); + if (this.history.length > HISTORY_LIMIT) this.history.shift(); + this.future = []; + this.emit("history"); + } + + beginInteraction(it) { + this.interaction = it; + this.changeStart = this.elements; + this.requestRender(); + } + + // record: false leaves the undo point to a label edit that follows (creation + typing = one step). + endInteraction({ revert = false, record = true } = {}) { + const start = this.changeStart; + this.changeStart = null; + this.interaction = null; + this.guides = []; + this.target = null; + this.marquee = null; + this.preview = null; + if (revert && start) this.setElements(start, { record: false }); + else if (record && start && !sameElements(start, this.elements)) this.pushHistory(start); + this.contentDirty = true; + this.requestRender(); + this.emit("change"); + this.emit("idle"); + } + + undo() { + if (this.busy) return; + const prev = this.history.pop(); + if (!prev) return; + this.future.push(this.elements); + this.setElements(prev, { record: false }); + this.emit("history"); + } + + redo() { + if (this.busy) return; + const next = this.future.pop(); + if (!next) return; + this.history.push(this.elements); + this.setElements(next, { record: false }); + this.emit("history"); + } + + load(doc) { + this.cancelPointer?.(); + if (this.editing) this.labels.close(); + this.elements = doc.elements; + this.byId = new Map(doc.elements.map((e) => [e.id, e])); + this.selection = new Set(); + this.history = []; + this.future = []; + this.hover = null; + this.linkFrom = null; + this.contentDirty = true; + this.fit(); + this.emit("history"); + this.emit("selection"); + this.emit("change", { loaded: true }); + } + + // Remote (agent) changes: undoable, and new content is brought into view. record: false is for + // the server tidying up our own change (a size past the limit, say), which is no step to undo. + applyRemote(next, { record = true } = {}) { + const before = this.elements; + if (sameJson(before, next)) return; + const known = new Set(before.map((e) => e.id)); + this.setElements(next, { record }); + const added = next.filter((e) => !known.has(e.id)); + if (!before.length) this.fit(); + else if (added.length && !this.isVisible(added)) this.fit({ maxZoom: this.view.zoom }); + } + + // ---------- creation helpers ---------- + + newShape(type, props = {}) { + return normalizeElement({ ...this.styles.shape, id: newId(idPrefix(type)), type, ...props }); + } + + newText(props = {}) { + return normalizeElement({ ...this.styles.text, id: newId("t"), type: "text", text: "", ...props }); + } + + newArrow(props = {}) { + return normalizeElement({ ...this.styles.arrow, id: newId("a"), type: "arrow", ...props }); + } + + obstacles(exclude = null) { + return this.elements + .filter((e) => e.type !== "arrow" && e.id !== exclude) + .map((e) => elementBounds(e, this.byId, this.measure)) + .filter(Boolean); + } + + // Adds a copy of a shape next to it in a direction, connected with an arrow, and edits its label. + addConnected(sourceId, dir = "right", { from = sourceId, anchorId = sourceId } = {}) { + const src = this.byId.get(sourceId); + const anchor = this.byId.get(anchorId); + if (!src || !isShape(src) || !anchor) return null; + const gap = dir === "left" || dir === "right" ? 80 : 60; + const spot = findFreeSpot(anchor, dir, src.w, src.h, this.obstacles(), gap); + const shape = normalizeElement({ ...src, id: newId(idPrefix(src.type)), text: "", x: snap(spot.x), y: snap(spot.y) }); + const next = [...this.elements, shape]; + if (from) next.push(this.newArrow({ from, to: shape.id })); + const before = this.elements; + this.setElements(next, { record: false }); + this.lastDirection = dir; + this.select([shape.id]); + this.ensureVisible([shape]); + this.labels.open(shape.id, { fresh: true, before }); + return shape; + } + + // Shift+Tab: another child of the same parent (or a plain copy below when there is no parent). + addSibling(id) { + const s = this.byId.get(id); + if (!s || !isShape(s)) return null; + const incoming = this.elements.find((e) => e.type === "arrow" && e.to === id && e.from); + const across = this.lastDirection === "down" || this.lastDirection === "up" ? "right" : "down"; + return this.addConnected(id, across, { from: incoming ? incoming.from : null }); + } + + // Enter with a shape or text tool adds one without a mouse: in the middle of the view, or the + // nearest free spot beside it. Then it edits the label, as a click on the canvas does. + addAtCenter(type) { + const text = type === "text"; + if (!text && !SHAPE_TYPES.includes(type)) return null; + const [w, h] = text ? [120, lineHeight(fontSizeOf(this.styles.text))] : DEFAULT_SIZES[type]; + const c = this.viewCenter(); + // The first spot findFreeSpot tries is one gap past its anchor, so this one centers it. + const spot = findFreeSpot({ x: c.x - w / 2 - 40, y: c.y - h / 2, w: 0, h }, "right", w, h, this.obstacles(), 40); + const at = { x: snap(spot.x), y: snap(spot.y) }; + const el = text ? this.newText(at) : this.newShape(type, { ...at, w, h }); + const before = this.elements; + this.setElements([...this.elements, el], { record: false }); + this.select([el.id]); + this.setTool("select"); + this.ensureVisible([el]); + this.labels.open(el.id, { fresh: !text, before }); + return el; + } + + // Enter with the arrow tool connects shapes without a mouse: the first Enter starts an arrow at + // the selected shape, and the next one ends it at the shape selected by then. + linkStep() { + const one = this.single(); + const from = this.linkFrom ? this.byId.get(this.linkFrom) : null; + if (!from) { + if (!one || !isShape(one)) return this.emit("hint", "Select a shape with N first, then press Enter to start an arrow from it."); + this.linkFrom = one.id; + this.requestRender(); + return this.emit("announce", `Arrow from ${describeElement(one, this.byId)}. Select the shape it goes to with N, then press Enter.`); + } + if (!one || !isShape(one) || one.id === from.id) { + return this.emit("hint", "Select the shape the arrow goes to with N, then press Enter. Esc cancels."); + } + const arrow = this.newArrow({ from: from.id, to: one.id }); + this.setElements([...this.elements, arrow]); + this.select([arrow.id]); + this.setTool("select"); + this.emit("announce", `Added an arrow from ${describeElement(from, this.byId)} to ${describeElement(one, this.byId)}.`); + } + + // ---------- selection ---------- + + selected() { + return this.elements.filter((e) => this.selection.has(e.id)); + } + + single() { + return this.selection.size === 1 ? this.byId.get([...this.selection][0]) || null : null; + } + + // N and Shift+N: select the next or previous element in drawing order, wrapping around. + selectStep(delta) { + const n = this.elements.length; + if (!n) return null; + const last = [...this.selection].pop(); + const at = last ? this.elements.findIndex((e) => e.id === last) : -1; + const index = at < 0 ? (delta > 0 ? 0 : n - 1) : (at + delta + n) % n; + const el = this.elements[index]; + this.select([el.id]); + this.ensureVisible([el]); + return { el, index, total: n }; + } + + select(ids, { toggle = false, add = false } = {}) { + const next = toggle || add ? new Set(this.selection) : new Set(); + for (const id of ids) { + if (!this.byId.has(id)) continue; + if (toggle && next.has(id)) next.delete(id); + else next.add(id); + } + if (next.size === this.selection.size && [...next].every((id) => this.selection.has(id))) return; + this.selection = next; + this.requestRender(); + this.emit("selection"); + } + + selectAll() { + this.setTool("select"); + this.select(this.elements.map((e) => e.id)); + } + + deleteSelection() { + if (!this.selection.size) return; + this.setElements(removeWithArrows(this.elements, [...this.selection]).elements); + } + + nudge(dx, dy) { + if (!this.selection.size) return; + this.setElements(this.elements.map((e) => (this.selection.has(e.id) ? translateElement(e, dx, dy) : e))); + } + + // Ctrl+Shift+Arrow: resizes the selected shapes, keeping their top left corner. Like dragging a + // handle, it stops at 20 px, but a shape that is smaller already keeps its size when shrunk. + resizeBy(dw, dh) { + const shapes = this.selected().filter(isShape); + if (!shapes.length) return this.emit("hint", "Select a shape to resize it."); + const sized = new Map(); + for (const s of shapes) { + const w = clamp(s.w + dw, Math.min(20, s.w), MAX_SIDE); + const h = clamp(s.h + dh, Math.min(20, s.h), MAX_SIDE); + if (w !== s.w || h !== s.h) sized.set(s.id, normalizeElement({ ...s, w, h })); + } + if (sized.size) this.setElements(this.elements.map((e) => sized.get(e.id) || e)); + const one = this.single(); + if (one && isShape(one)) this.emit("announce", `${Math.round(one.w)} by ${Math.round(one.h)}`); + } + + reorder(toFront) { + if (!this.selection.size) return; + const picked = this.elements.filter((e) => this.selection.has(e.id)); + if (picked.length && picked.every((e) => e.type === "arrow")) { + this.emit("hint", "Arrows always stay on top of shapes."); + return; + } + const rest = this.elements.filter((e) => !this.selection.has(e.id)); + this.setElements(toFront ? [...rest, ...picked] : [...picked, ...rest]); + } + + // Copies of elements with fresh ids. Arrow ends bound outside the set become free ends. + cloneSet(list, dx, dy) { + const ids = new Map(list.map((e) => [e.id, newId(idPrefix(e.type))])); + const byId = new Map([...this.byId, ...list.map((e) => [e.id, e])]); + const out = []; + for (const el of list) { + let copy = { ...el, id: ids.get(el.id) }; + if (el.type === "arrow") { + const g = arrowGeometry(el, byId); + if (!g) continue; + copy.from = ids.get(el.from) || null; + copy.to = ids.get(el.to) || null; + if (!copy.from) { copy.x1 = round(g.start.x); copy.y1 = round(g.start.y); } + if (!copy.to) { copy.x2 = round(g.end.x); copy.y2 = round(g.end.y); } + } + copy = normalizeElement(translateElement(copy, dx, dy)); + if (copy) out.push(copy); + } + return out; + } + + duplicate() { + const list = this.selected(); + if (!list.length) return; + const copies = this.cloneSet(list, 20, 20); + this.setElements([...this.elements, ...copies]); + this.select(copies.map((e) => e.id)); + } + + copyText() { + const list = this.selected(); + if (!list.length) return null; + return JSON.stringify({ type: "copilot-draw", version: 1, elements: this.cloneSet(list, 0, 0) }); + } + + paste(text) { + let data = null; + try { + data = JSON.parse(text); + } catch { + // Plain text. + } + const p = this.lastPointer || this.viewCenter(); + if (data && data.type === "copilot-draw" && Array.isArray(data.elements)) { + const list = normalizeElements(data.elements); + if (!list.length) return; + const b = contentBounds(list, this.measure); + const dx = snap(p.x - (b.x + b.w / 2)); + const dy = snap(p.y - (b.y + b.h / 2)); + const copies = this.cloneSet(list, dx, dy); + this.setElements([...this.elements, ...copies]); + this.select(copies.map((e) => e.id)); + return; + } + const str = String(text).trim(); + if (!str) return; + const el = this.newText({ text: str.slice(0, 4000), x: snap(p.x), y: snap(p.y) }); + this.setElements([...this.elements, el]); + this.select([el.id]); + } + + // ---------- styles ---------- + + toolCategory() { + if (SHAPE_TYPES.includes(this.tool)) return "shape"; + return ["text", "arrow", "pen"].includes(this.tool) ? this.tool : null; + } + + // Patch keys: color, fill, dash, head, route, size, width, type. Only keys that fit each element apply. + applyStyle(patch) { + const sel = this.selected(); + const pick = (cat) => Object.fromEntries(Object.entries(patch).filter(([k]) => STYLE_KEYS[cat].includes(k))); + if (sel.length) { + const next = this.elements.map((el) => { + if (!this.selection.has(el.id)) return el; + const p = pick(categoryOf(el)); + if (!Object.keys(p).length) return el; + let out = normalizeElement({ ...el, ...p }) || el; + if (isShape(out)) { + // Tall enough for the label, up to the size limit, so it stays centered where it was. + const need = Math.min(MAX_SIDE, Math.ceil(neededHeight(out, this.measure) / 10) * 10); + if (need > out.h) out = { ...out, y: round(out.y - (need - out.h) / 2), h: need }; + } + return out; + }); + this.setElements(next); + for (const el of sel) { + const { type, ...rest } = pick(categoryOf(el)); + Object.assign(this.styles[categoryOf(el)], rest); + } + } else { + const cat = this.toolCategory(); + if (cat) { + const { type, ...rest } = pick(cat); + Object.assign(this.styles[cat], rest); + } + } + this.emit("style"); + } + + setTool(tool) { + if (tool === this.tool) return; + if (this.editing) this.labels.commit(); + this.tool = tool; + this.linkFrom = null; + if (tool !== "select") this.hover = null; + this.requestRender(); + this.emit("tool", tool); + } + + // The canvas is the SVG: it takes the keys, and screen readers hear it as the drawing. + focusCanvas() { + this.svg.focus({ preventScroll: true }); + } + + setTheme(theme) { + this.theme = theme; + this.contentDirty = true; + this.requestRender(); + } + + refresh() { + this.contentDirty = true; + this.requestRender(); + } + + // ---------- view ---------- + + viewport() { + const r = this.stage.getBoundingClientRect(); + return { w: r.width, h: r.height, left: r.left, top: r.top }; + } + + toWorld(clientX, clientY) { + const r = this.stage.getBoundingClientRect(); + return { x: (clientX - r.left) / this.view.zoom + this.view.x, y: (clientY - r.top) / this.view.zoom + this.view.y }; + } + + toScreen(x, y) { + return { x: (x - this.view.x) * this.view.zoom, y: (y - this.view.y) * this.view.zoom }; + } + + viewCenter() { + const { w, h } = this.viewport(); + return { x: this.view.x + w / 2 / this.view.zoom, y: this.view.y + h / 2 / this.view.zoom }; + } + + setView(view) { + this.view = view; + this.requestRender(); + this.emit("view"); + } + + panBy(dx, dy) { + this.setView({ ...this.view, x: this.view.x + dx / this.view.zoom, y: this.view.y + dy / this.view.zoom }); + } + + zoomAt(factor, sx, sy) { + const z0 = this.view.zoom; + const z = clamp(z0 * factor, MIN_ZOOM, MAX_ZOOM); + const wx = sx / z0 + this.view.x; + const wy = sy / z0 + this.view.y; + this.setView({ zoom: z, x: wx - sx / z, y: wy - sy / z }); + } + + zoomBy(factor) { + const { w, h } = this.viewport(); + this.zoomAt(factor, w / 2, h / 2); + } + + zoomTo(z) { + this.zoomBy(z / this.view.zoom); + } + + // Room left for the floating tool rail, style bar and zoom controls. + insets() { + return { l: 64, r: 24, t: 56, b: 52 }; + } + + fitBounds(b, maxZoom = 1) { + const { w, h } = this.viewport(); + if (!w || !h) return; + const m = this.insets(); + const aw = Math.max(80, w - m.l - m.r); + const ah = Math.max(80, h - m.t - m.b); + const zoom = clamp(Math.min(aw / Math.max(b.w, 1), ah / Math.max(b.h, 1), maxZoom), MIN_ZOOM, MAX_ZOOM); + const cx = m.l + aw / 2; + const cy = m.t + ah / 2; + this.setView({ zoom, x: b.x + b.w / 2 - cx / zoom, y: b.y + b.h / 2 - cy / zoom }); + } + + fit({ maxZoom = 1 } = {}) { + const { w, h } = this.viewport(); + if (!w || !h) { + this.needsFit = true; + return; + } + const b = contentBounds(this.elements, this.measure); + if (b) return this.fitBounds(b, maxZoom); + this.setView({ zoom: 1, x: -Math.round(w / 2), y: -Math.round(h / 2) }); + } + + isVisible(list) { + const b = unionBounds(list.map((e) => elementBounds(e, this.byId, this.measure))); + if (!b) return true; + const { w, h } = this.viewport(); + const z = this.view.zoom; + const v = { x: this.view.x, y: this.view.y, w: w / z, h: h / z }; + return b.x >= v.x && b.y >= v.y && b.x + b.w <= v.x + v.w && b.y + b.h <= v.y + v.h; + } + + ensureVisible(list) { + if (this.isVisible(list)) return; + const b = unionBounds(list.map((e) => elementBounds(e, this.byId, this.measure))); + if (!b) return; + const { w, h } = this.viewport(); + const z = this.view.zoom; + if (b.w * z < w - 120 && b.h * z < h - 120) { + this.setView({ zoom: z, x: b.x + b.w / 2 - w / 2 / z, y: b.y + b.h / 2 - h / 2 / z }); + } else { + this.fitBounds(b, z); + } + } + + // ---------- export ---------- + + exportSVG({ background = true } = {}) { + return renderStandaloneSVG(this.elements, { + measure: this.measure, + paint: this.paint, + fontFamily: this.theme.font, + background: background ? this.theme.bg : null, + }); + } + + async exportPNG(scale = 2, maxSide = 8000) { + const svg = this.exportSVG(); + const m = /width="(\d+)" height="(\d+)"/.exec(svg); + const w = Number(m[1]); + const h = Number(m[2]); + const img = new Image(); + img.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg); + await img.decode(); + const k = Math.min(scale, maxSide / Math.max(w, h)); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.round(w * k)); + canvas.height = Math.max(1, Math.round(h * k)); + canvas.getContext("2d").drawImage(img, 0, 0, canvas.width, canvas.height); + return new Promise((resolve, reject) => canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Could not create the PNG."))), "image/png")); + } + + // ---------- handles ---------- + + handles() { + const it = this.interaction; + if (this.editing || (it && it.kind !== "resize" && it.kind !== "endpoint")) return []; + const out = []; + const one = this.single(); + if (one && isShape(one)) { + for (const [which, x, y] of [["nw", one.x, one.y], ["ne", one.x + one.w, one.y], ["sw", one.x, one.y + one.h], ["se", one.x + one.w, one.y + one.h]]) { + out.push({ kind: "resize", id: one.id, which, x, y, r: 4 }); + } + } else if (one && one.type === "arrow") { + const g = arrowGeometry(one, this.byId); + if (g) { + out.push({ kind: "end", id: one.id, which: "start", x: g.start.x, y: g.start.y, r: 5, bound: !!one.from }); + out.push({ kind: "end", id: one.id, which: "end", x: g.end.x, y: g.end.y, r: 5, bound: !!one.to }); + } + } + if (!it && this.tool === "select") { + const id = this.hover || (one && isShape(one) ? one.id : null); + const s = id ? this.byId.get(id) : null; + const z = this.view.zoom; + if (s && isShape(s) && s.w * z >= 30 && s.h * z >= 20) { + const off = 20 / z; + out.push( + { kind: "connect", id: s.id, which: "right", x: s.x + s.w + off, y: s.y + s.h / 2, r: 8 }, + { kind: "connect", id: s.id, which: "down", x: s.x + s.w / 2, y: s.y + s.h + off, r: 8 }, + { kind: "connect", id: s.id, which: "left", x: s.x - off, y: s.y + s.h / 2, r: 8 }, + { kind: "connect", id: s.id, which: "up", x: s.x + s.w / 2, y: s.y - off, r: 8 }, + ); + } + } + return out; + } + + handleAt(sx, sy) { + const list = this.handles(); + for (let i = list.length - 1; i >= 0; i--) { + const h = list[i]; + const p = this.toScreen(h.x, h.y); + if (Math.hypot(p.x - sx, p.y - sy) <= h.r + 4) return h; + } + return null; + } + + // ---------- rendering ---------- + + requestRender() { + if (!this.raf) this.raf = requestAnimationFrame(() => this.render()); + } + + render() { + this.raf = 0; + const { x, y, zoom } = this.view; + this.world.setAttribute("transform", `scale(${zoom}) translate(${fmt(-x)} ${fmt(-y)})`); + if (this.contentDirty) { + this.content.innerHTML = renderElements(this.elements, { + measure: this.measure, + paint: this.paint, + hideText: this.editing ? this.editing.id : null, + }); + this.contentDirty = false; + } + this.overlay.innerHTML = this.renderOverlay(); + let step = 20; + while (step * zoom < 12) step *= 5; + const px = step * zoom; + this.stage.style.backgroundSize = `${px}px ${px}px`; + this.stage.style.backgroundPosition = `${-x * zoom}px ${-y * zoom}px`; + this.emit("render"); + } + + renderOverlay() { + const u = 1 / this.view.zoom; + const A = this.theme.accent; + const bg = this.theme.bg; + const sw = (px) => fmt(px * u); + const box = (b, pad, attrs) => + ``; + const parts = []; + if (this.preview) { + const p = this.preview; + parts.push(``); + } + const hovered = this.hover && !this.selection.has(this.hover) && !this.interaction ? this.byId.get(this.hover) : null; + if (hovered) parts.push(outline(hovered, ` fill="none" stroke="${A}" stroke-width="${sw(1.5)}" opacity="0.45"`)); + const target = this.target ? this.byId.get(this.target) : null; + if (target) parts.push(outline(target, ` fill="${A}" fill-opacity="0.08" stroke="${A}" stroke-width="${sw(2)}"`)); + // An arrow started from the keyboard: its start, and a dashed line to the selected shape. + const from = this.linkFrom ? this.byId.get(this.linkFrom) : null; + if (from) { + parts.push(outline(from, ` fill="${A}" fill-opacity="0.08" stroke="${A}" stroke-width="${sw(2)}"`)); + const to = this.single(); + const g = to && isShape(to) && to.id !== from.id ? arrowGeometry(this.newArrow({ id: "link", from: from.id, to: to.id }), this.byId) : null; + if (g) parts.push(``); + } + const sel = this.selected(); + for (const el of sel) { + if (el.type === "arrow") { + const g = arrowGeometry(el, this.byId); + if (g) parts.push(``); + continue; + } + const b = elementBounds(el, this.byId, this.measure); + if (b) parts.push(box(b, 4 * u, `fill="none" stroke="${A}" stroke-width="${sw(1.5)}" rx="${sw(3)}"`)); + } + if (sel.length > 1 && this.interaction?.kind !== "move") { + const b = unionBounds(sel.map((e) => elementBounds(e, this.byId, this.measure))); + if (b) parts.push(box(b, 10 * u, `fill="none" stroke="${A}" stroke-width="${sw(1)}" stroke-dasharray="${sw(4)} ${sw(4)}"`)); + } + for (const h of this.handles()) parts.push(this.renderHandle(h, u, A, bg)); + if (this.marquee) parts.push(box(this.marquee, 0, `fill="${A}" fill-opacity="0.07" stroke="${A}" stroke-width="${sw(1)}"`)); + for (const g of this.guides) { + parts.push(``); + } + return parts.join(""); + } + + renderHandle(h, u, A, bg) { + const r = h.r * u; + const x = fmt(h.x); + const y = fmt(h.y); + if (h.kind === "resize") { + return ``; + } + if (h.kind === "end") { + return ``; + } + const hot = this.hoverHandle === `${h.kind}:${h.id}:${h.which}`; + const arm = 3.5 * u; + return `` + + ``; + } +} diff --git a/extensions/draw/public/icons.js b/extensions/draw/public/icons.js new file mode 100644 index 000000000..a640930c6 --- /dev/null +++ b/extensions/draw/public/icons.js @@ -0,0 +1,44 @@ +// 16px stroke icons. Stroke, caps and joins come from the .icon class. +const ICONS = { + select: '', + hand: '', + rect: '', + ellipse: '', + diamond: '', + cylinder: '', + arrow: '', + text: '', + pen: '', + undo: '', + redo: '', + minus: '', + plus: '', + fit: '', + help: '', + front: '', + back: '', + duplicate: '', + trash: '', + "fill-none": '', + "fill-soft": '', + "fill-solid": '', + "line-solid": '', + "line-dashed": '', + "head-end": '', + "head-both": '', + "head-none": '', + "route-straight": '', + "route-curve": '', + "route-elbow": '', + folder: '', + copy: '', + download: '', + edit: '', + sparkle: '', + sun: '', + moon: '', + contrast: '', + check: '', +}; + +export const icon = (name) => ``; diff --git a/extensions/draw/public/index.html b/extensions/draw/public/index.html new file mode 100644 index 000000000..bb96d6ce5 --- /dev/null +++ b/extensions/draw/public/index.html @@ -0,0 +1,59 @@ + + + + + +Draw + + + +
+
+
+ + + +
+
+ + + + + + +
+
+ +
+ + + + +
+ + + +
+ + + + +
+ +
+ +
+
+
+
+ + + diff --git a/extensions/draw/public/keys.js b/extensions/draw/public/keys.js new file mode 100644 index 000000000..6b48b0386 --- /dev/null +++ b/extensions/draw/public/keys.js @@ -0,0 +1,148 @@ +// Keyboard shortcuts and clipboard. +import { isShape, SHAPE_TYPES } from "/lib/model.mjs"; +import { describeElement } from "./announce.js"; + +export const TOOL_KEYS = { v: "select", h: "hand", r: "rect", o: "ellipse", d: "diamond", c: "cylinder", a: "arrow", t: "text", p: "pen" }; +const DIRS = { ArrowRight: "right", ArrowLeft: "left", ArrowDown: "down", ArrowUp: "up" }; +const STEPS = { right: [1, 0], left: [-1, 0], down: [0, 1], up: [0, -1] }; + +export function attachKeys(ed, ui) { + const typing = (target) => + !!target && target !== ed.svg && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || !!target.closest?.(".popover")); + // A focused button keeps its normal keys: Space and Enter press it, and Tab moves focus on. + const control = (target) => + !!target && target !== ed.svg && !!target.closest?.("button, a[href], select, summary, [role='button']"); + // N and Shift+N reach every element in turn, and say which one is selected. + const selectStep = (delta) => { + const hit = ed.selectStep(delta); + ui.announce(hit ? `${describeElement(hit.el, ed.byId)}, ${hit.index + 1} of ${hit.total}` : "The drawing is empty."); + }; + + // The canvas takes focus on every click and after every label edit, so it only shows a focus ring + // while the keyboard is in use: a click hides the ring, and Tab brings it back. + ed.svg.classList.add("pointer-used"); + window.addEventListener("pointerdown", () => ed.svg.classList.add("pointer-used"), true); + + window.addEventListener("keydown", (e) => { + if (e.key === "Tab") ed.svg.classList.remove("pointer-used"); + if (e.defaultPrevented || e.isComposing || typing(e.target)) return; + const key = e.key; + if ((key === " " || key === "Enter" || key === "Tab") && control(e.target)) return; + const lower = key.length === 1 ? key.toLowerCase() : key; + const mod = e.ctrlKey || e.metaKey; + const one = ed.single(); + const done = () => e.preventDefault(); + + if (key === " ") { + done(); + if (!e.repeat && !ed.spaceDown) { + ed.spaceDown = true; + ed.refreshCursor?.(); + } + return; + } + if (key === "Escape") { + if (ed.pointerActive?.()) ed.cancelPointer(); + else if (ui.closePopover()) ed.focusCanvas(); + // From the tools, the style bar or the zoom buttons, Esc goes back to the canvas. + else if (e.target !== ed.svg && ed.stage.contains(e.target)) ed.focusCanvas(); + else if (ed.tool !== "select") { + ed.setTool("select"); + ui.announceTool("select"); + } else ed.select([]); + return done(); + } + if (mod) { + if (lower === "z") { + if (e.shiftKey) ed.redo(); + else ed.undo(); + return done(); + } + if (lower === "y") { ed.redo(); return done(); } + if (lower === "a") { ed.selectAll(); return done(); } + if (lower === "d") { ed.duplicate(); return done(); } + if (lower === "s") { ui.toast("Drawings save automatically."); return done(); } + if (key === "0") { ed.zoomTo(1); return done(); } + if (key === "=" || key === "+") { ed.zoomBy(1.25); return done(); } + if (key === "-" || key === "_") { ed.zoomBy(0.8); return done(); } + if (key === "]") { ed.reorder(true); return done(); } + if (key === "[") { ed.reorder(false); return done(); } + if (DIRS[key] && e.shiftKey) { + const [dx, dy] = STEPS[DIRS[key]]; + if (!ed.interaction) ed.resizeBy(dx * 10, dy * 10); + return done(); + } + if (DIRS[key] && one && isShape(one)) { ed.addConnected(one.id, DIRS[key]); return done(); } + return; + } + if (e.altKey && !DIRS[key]) return; + if (key === "Delete" || key === "Backspace") { + const gone = ed.selected(); + const what = gone.length === 1 ? describeElement(gone[0], ed.byId) : `${gone.length} elements`; + ed.deleteSelection(); + if (gone.length) ui.announce(`Deleted ${what}.`); + return done(); + } + // With a shape or text tool, Enter adds one. With the arrow tool, it starts or ends an arrow. + if (key === "Enter" && !ed.interaction && (SHAPE_TYPES.includes(ed.tool) || ed.tool === "text")) { + ed.addAtCenter(ed.tool); + return done(); + } + if (key === "Enter" && !ed.interaction && ed.tool === "arrow") { ed.linkStep(); return done(); } + if ((key === "Enter" || key === "F2") && one && one.type !== "pen") { ed.labels.open(one.id); return done(); } + if (lower === "n") { + if (!ed.interaction) selectStep(e.shiftKey ? -1 : 1); + return done(); + } + if (DIRS[key]) { + const px = e.altKey ? 1 : e.shiftKey ? 50 : 10; + const [dx, dy] = STEPS[DIRS[key]]; + if (ed.selection.size) ed.nudge(dx * px, dy * px); + else ed.panBy(dx * px * 4, dy * px * 4); + return done(); + } + if (key === "!" || (e.shiftKey && e.code === "Digit1")) { ed.fit({ maxZoom: 2 }); return done(); } + if (key === "?") { ui.toggleHelp(); return done(); } + if (!e.shiftKey && TOOL_KEYS[lower]) { + ed.setTool(TOOL_KEYS[lower]); + ui.announceTool(TOOL_KEYS[lower]); + return done(); + } + }); + + window.addEventListener("keyup", (e) => { + if (e.key === " " && ed.spaceDown) { + ed.spaceDown = false; + ed.refreshCursor?.(); + } + }); + window.addEventListener("blur", () => { + if (ed.spaceDown) { + ed.spaceDown = false; + ed.refreshCursor?.(); + } + }); + + document.addEventListener("copy", (e) => { + if (typing(e.target) || !ed.selection.size) return; + const text = ed.copyText(); + if (!text) return; + e.clipboardData.setData("text/plain", text); + e.preventDefault(); + }); + document.addEventListener("cut", (e) => { + if (typing(e.target) || !ed.selection.size) return; + const text = ed.copyText(); + if (!text) return; + e.clipboardData.setData("text/plain", text); + ed.deleteSelection(); + e.preventDefault(); + }); + document.addEventListener("paste", (e) => { + if (typing(e.target)) return; + const text = e.clipboardData?.getData("text/plain"); + if (!text) return; + ed.paste(text); + e.preventDefault(); + }); +} diff --git a/extensions/draw/public/labels.js b/extensions/draw/public/labels.js new file mode 100644 index 000000000..81feaa4d9 --- /dev/null +++ b/extensions/draw/public/labels.js @@ -0,0 +1,188 @@ +// In-place label editing with a textarea laid over the element. +import { isShape, normalizeElement, LABEL_WEIGHT, TEXT_WEIGHT, MAX_SIDE } from "/lib/model.mjs"; +import { + labelMaxWidth, labelCenterY, lineHeight, fontSizeOf, textLayout, neededHeight, arrowGeometry, +} from "/lib/geometry.mjs"; +import { autoSize } from "/lib/layout.mjs"; +import { TYPE_NAMES } from "./announce.js"; + +export class LabelEditor { + constructor(ed) { + this.ed = ed; + this.ta = null; + ed.labels = this; + ed.on("render", () => this.position()); + } + + // fresh: the shape was just created, so it may widen to fit the text. + // before: elements to use as the undo point (when the element was created just for this edit). + open(id, { fresh = false, before = null } = {}) { + const ed = this.ed; + if (ed.editing) this.commit(); + const el = ed.byId.get(id); + if (!el || el.type === "pen") return; + const ta = document.createElement("textarea"); + ta.className = "label-editor"; + ta.setAttribute("aria-label", el.type === "text" ? "Text" : `${TYPE_NAMES[el.type] || "Shape"} label`); + ta.spellcheck = true; + ta.value = el.text || ""; + ed.layer.append(ta); + this.ta = ta; + ed.editing = { id, fresh, before: before || ed.elements, start: { x: el.x, y: el.y, w: el.w, h: el.h } }; + ed.contentDirty = true; + ta.addEventListener("input", () => this.input()); + ta.addEventListener("keydown", (e) => this.keydown(e)); + ta.addEventListener("blur", () => { + if (this.ta === ta) this.commit({ refocus: false }); + }); + ed.requestRender(); + this.position(); + ta.focus({ preventScroll: true }); + ta.setSelectionRange(ta.value.length, ta.value.length); + ed.emit("editing", true); + } + + input() { + const ed = this.ed; + const st = ed.editing; + const el = st && ed.byId.get(st.id); + if (!el) return this.close(); + let next = { ...el, text: this.ta.value }; + if (isShape(el)) next = this.grow(next, st); + ed.replaceElement(normalizeElement(next) || next, { record: false, emit: false }); + } + + // Grows the shape to fit its label (never below its size when editing started, never past the + // size limit), keeping the center. + grow(el, st) { + const measure = this.ed.measure; + let w = st.start.w; + if (st.fresh) w = Math.max(w, autoSize(el.text, el.type, measure, el.size).w); + const h = Math.min(MAX_SIDE, Math.max(st.start.h, Math.ceil(neededHeight({ ...el, w }, measure) / 10) * 10)); + const cx = st.start.x + st.start.w / 2; + const cy = st.start.y + st.start.h / 2; + return { ...el, w, h, x: Math.round(cx - w / 2), y: Math.round(cy - h / 2) }; + } + + keydown(e) { + e.stopPropagation(); + if (e.isComposing || e.keyCode === 229) return; + const ed = this.ed; + if (e.key === "Escape" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + this.commit(); + return; + } + if (e.key === "Tab") { + const id = ed.editing.id; + const el = ed.byId.get(id); + // Only a shape has a next step to add. Anywhere else Tab moves focus on as usual, and the + // blur saves the edit. + if (!el || !isShape(el)) return; + e.preventDefault(); + this.commit(); + if (e.shiftKey) ed.addSibling(id); + else ed.addConnected(id, ed.lastDirection || "right"); + } + } + + position() { + const ed = this.ed; + const st = ed.editing; + const ta = this.ta; + const el = st && ta && ed.byId.get(st.id); + if (!el) return; + const z = ed.view.zoom; + const text = ta.value || " "; + let fs; + let weight; + let left; + let width; + let cy = null; + let top = null; + let align = "center"; + let wrap = true; + let color; + let background = "transparent"; + if (isShape(el)) { + fs = fontSizeOf(el); + weight = LABEL_WEIGHT; + width = labelMaxWidth(el); + left = el.x + el.w / 2 - width / 2; + cy = labelCenterY(el); + color = el.fill === "solid" ? ed.paint(el.color, "onSolid") : ed.paint("gray", "ink"); + } else if (el.type === "text") { + const l = textLayout({ ...el, text }, ed.measure); + fs = l.fs; + weight = TEXT_WEIGHT; + width = l.w + fs; + left = el.x; + top = el.y; + align = "left"; + wrap = false; + color = ed.paint(el.color, "ink"); + } else { + const g = arrowGeometry(el, ed.byId); + if (!g) return; + fs = fontSizeOf(el); + weight = TEXT_WEIGHT; + const widest = Math.max(...text.split("\n").map((s) => ed.measure(s, fs, weight))); + width = widest + fs * 2; + left = g.mid.x - width / 2; + cy = g.mid.y; + wrap = false; + color = ed.paint(el.color, "ink"); + background = ed.theme.bg; + } + const lh = lineHeight(fs); + Object.assign(ta.style, { + width: `${width * z}px`, + font: `${weight} ${fs * z}px/${lh * z}px ${ed.theme.font}`, + textAlign: align, + color, + background, + height: "0px", + }); + ta.classList.toggle("no-wrap", !wrap); + const h = Math.max(ta.scrollHeight, lh * z); + ta.style.height = `${h}px`; + const s = ed.toScreen(left, top ?? cy); + ta.style.left = `${s.x}px`; + ta.style.top = `${top !== null ? s.y : s.y - h / 2}px`; + } + + // Ends editing and records one undo step. Empty text elements are removed. + commit({ refocus = true } = {}) { + const ed = this.ed; + const st = ed.editing; + if (!st) return; + const ta = this.ta; + this.ta = null; + ed.editing = null; + ta.remove(); + const el = ed.byId.get(st.id); + if (el) { + const text = String(el.text || "").replace(/\s+$/, ""); + if (el.type === "text" && !text.trim()) ed.setElements(ed.elements.filter((e) => e.id !== el.id), { record: false, emit: false }); + else if (text !== el.text) ed.replaceElement({ ...el, text }, { record: false, emit: false }); + } + if (ed.elements !== st.before && JSON.stringify(ed.elements) !== JSON.stringify(st.before)) ed.pushHistory(st.before); + ed.contentDirty = true; + ed.requestRender(); + ed.emit("editing", false); + ed.emit("change"); + ed.emit("idle"); + if (refocus) ed.focusCanvas(); + } + + // Drops the editor without recording anything (used when a different drawing is loaded). + close() { + const ed = this.ed; + if (this.ta) this.ta.remove(); + this.ta = null; + ed.editing = null; + ed.contentDirty = true; + ed.requestRender(); + ed.emit("editing", false); + } +} diff --git a/extensions/draw/public/menus.js b/extensions/draw/public/menus.js new file mode 100644 index 000000000..e2bdc8a82 --- /dev/null +++ b/extensions/draw/public/menus.js @@ -0,0 +1,325 @@ +// Top bar menus: drawings, theme, export, Ask Copilot, and the shortcuts help. +import { icon } from "./icons.js"; +import { esc, blobToBase64, fileName } from "./util.js"; + +const $ = (id) => document.getElementById(id); + +const CHIPS = ["Tidy up the layout", "What is missing?", "Explain this diagram", "Suggest improvements"]; + +const THEME_CHOICES = [ + ["app", "contrast", "Match app"], + ["light", "sun", "Light"], + ["dark", "moon", "Dark"], +]; + +const HELP = [ + [["V", "H"], "Select, pan"], + [["R", "O", "D", "C"], "Rectangle, ellipse, diamond, database"], + [["A", "T", "P"], "Arrow, text, pen"], + [["N"], "Select the next element (Shift+N goes back)"], + [["Enter"], "Edit the label (or double-click it)"], + [["Enter"], "With a shape or text tool: add one in the middle"], + [["Enter"], "With the arrow tool: start an arrow at the selected shape, then again to end it at the next one you select"], + ["Click +", "Add the next connected shape"], + ["Drag +", "Connect to a shape, or drop to add one"], + [["Tab"], "While typing a shape's label: add the next step (Shift+Tab adds a sibling)"], + ["Double-click", "Empty space adds text"], + [["Ctrl+Arrow"], "Add a shape in that direction"], + [["Arrows"], "Nudge (Alt: 1px, Shift: 50px)"], + [["Ctrl+Shift+Arrow"], "Resize the selected shape"], + ["Shift+drag", "Keep proportions or lock the axis"], + ["Alt+drag", "Turn off snapping"], + ["Space+drag", "Pan (scrolling pans too)"], + ["Ctrl+scroll", "Zoom"], + [["Shift+1"], "Fit the drawing"], + [["Ctrl+Z"], "Undo (Ctrl+Shift+Z redoes)"], + [["Ctrl+C", "Ctrl+V", "Ctrl+D"], "Copy, paste, duplicate"], + [["Del"], "Delete the selection"], + [["Esc"], "Cancel, or go back to the canvas from a toolbar"], +]; + +const menuItem = (act, iconName, label, cls = "") => + ``; + +export function attachMenus(ui) { + const ed = ui.ed; + + // Runs a drawings menu command. Commands that show another drawing first make sure the edits + // on screen are saved, since switching would lose them, unless the user chose to discard them. + async function drawingsAction(body, { discard = false } = {}) { + try { + const switches = body.action === "new" || body.action === "open" || body.action === "duplicate"; + // Discarded edits are not worth sending. + if (!discard && !(await ui.sync.flush(true)) && switches) { + ui.warnUnsaved(() => drawingsAction(body, { discard: true })); + return null; + } + const res = await ui.sync.post("drawings", body); + ui.setDrawings(res.drawings); + // A new name never moves the panel, even if it has moved to another drawing meanwhile. + if (body.action === "rename") return res; + if (res.drawing.id !== ui.sync.drawingId) { + if (!(await ui.sync.switchTo(res.drawing, { discard }))) return null; + } else ui.setDoc(res.drawing); + return res; + } catch (err) { + ui.toast(err.message, { error: true }); + return null; + } + } + ui.drawingsAction = drawingsAction; + + async function reveal(path) { + try { + await ui.sync.post("reveal", { path }); + } catch (err) { + ui.toast(err.message, { error: true }); + } + } + + function openDrawingsMenu() { + ui.togglePopover($("doc-button"), (pop, close) => { + const cur = ui.doc && ui.doc.id; + const list = ui.drawings.map((d) => { + const count = d.id === cur ? ed.elements.length : d.count; + return ``; + }).join(""); + const render = () => { + pop.innerHTML = + `${list}` + + menuItem("new", "plus", "New drawing") + menuItem("rename", "edit", "Rename") + + menuItem("duplicate", "duplicate", "Duplicate") + menuItem("reveal", "folder", "Show in folder") + + `${menuItem("delete", "trash", "Delete drawing", "danger")}`; + }; + render(); + pop.addEventListener("click", async (e) => { + const b = e.target.closest("button"); + if (!b) return; + if (b.dataset.open) { + close(); + if (b.dataset.open !== cur) drawingsAction({ action: "open", id: b.dataset.open }); + return; + } + const act = b.dataset.act; + if (act === "new") { + close(); + drawingsAction({ action: "new" }); + } else if (act === "rename") { + close(); + startRename(); + } else if (act === "duplicate") { + close(); + const res = await drawingsAction({ action: "duplicate", id: cur }); + if (res) ui.toast(`Made a copy: ${res.drawing.name}`); + } else if (act === "reveal") { + close(); + reveal(`${ui.folder}/${cur}.json`); + } else if (act === "delete") { + // The confirmation is a small dialog, not a menu. + pop.setAttribute("role", "alertdialog"); + pop.setAttribute("aria-label", "Delete drawing"); + pop.setAttribute("aria-describedby", "delete-note"); + pop.innerHTML = + `` + + ''; + pop.querySelector("[data-act=cancel]").focus(); + } else if (act === "cancel") { + close(); + } else if (act === "confirm-delete") { + const name = ui.doc.name; + close(); + const res = await drawingsAction({ action: "delete", id: cur }); + if (res) ui.toast(`Deleted "${name}".`); + } + }); + pop.querySelector(".is-current, .menu-item")?.focus(); + }, { className: "menu", role: "menu", label: "Drawings" }); + } + + function startRename() { + const button = $("doc-button"); + const input = $("doc-rename"); + // The panel can move to another drawing before the new name is in (the agent can open one), + // and the name belongs to the drawing it was typed for. + const doc = ui.doc; + input.value = doc ? doc.name : ""; + button.hidden = true; + input.hidden = false; + input.focus(); + input.select(); + let finished = false; + const finish = async (save) => { + if (finished) return; + finished = true; + ui.cancelRename = null; + input.hidden = true; + button.hidden = false; + const name = input.value.trim(); + if (save && name && doc && doc.id === ui.doc?.id && name !== doc.name) await drawingsAction({ action: "rename", id: doc.id, name }); + ed.focusCanvas(); + }; + ui.cancelRename = () => finish(false); + input.onkeydown = (e) => { + e.stopPropagation(); + if (e.key === "Enter") { + e.preventDefault(); + finish(true); + } else if (e.key === "Escape") { + e.preventDefault(); + finish(false); + } + }; + input.onblur = () => finish(true); + } + + function openExportMenu() { + ui.togglePopover($("export-button"), (pop, close) => { + pop.innerHTML = + menuItem("copy", "copy", "Copy as image") + menuItem("png", "download", "Save as PNG") + menuItem("svg", "download", "Save as SVG") + + ''; + pop.addEventListener("click", (e) => { + const b = e.target.closest("button[data-act]"); + if (!b) return; + close(); + exportAs(b.dataset.act); + }); + pop.querySelector(".menu-item").focus(); + }, { className: "menu", role: "menu", align: "end", label: "Export" }); + } + + async function exportAs(kind) { + if (!ed.elements.length) { + ui.toast("Draw something first."); + return; + } + let note = "Saved"; + if (kind === "copy") { + try { + if (!navigator.clipboard || !navigator.clipboard.write || typeof ClipboardItem === "undefined") throw new Error("unsupported"); + await navigator.clipboard.write([new ClipboardItem({ "image/png": ed.exportPNG() })]); + ui.toast("Copied the drawing as an image."); + return; + } catch { + kind = "png"; + note = "Copying images is blocked here, so it was saved"; + } + } + try { + // The picture shows the drawing on screen right now, so it is saved under that drawing + // even if another one opens while the picture is being made. + const drawingId = ui.doc.id; + const data = kind === "svg" ? ed.exportSVG() : await blobToBase64(await ed.exportPNG()); + const res = await ui.sync.post("export", { drawingId, format: kind, data }); + ui.toast(`${note} as ${fileName(res.path)}`, { action: { label: "Show in folder", run: () => reveal(res.path) } }); + } catch (err) { + ui.toast(`Export failed: ${err.message}`, { error: true }); + } + } + + function openAsk() { + ui.togglePopover($("ask-button"), (pop, close) => { + pop.innerHTML = + '

Ask Copilot about this drawing

' + + '

Copilot gets a picture of the drawing and can change it for you.

' + + '' + + `
${CHIPS.map((c) => ``).join("")}
` + + '
Ctrl+Enter to send
'; + const ta = pop.querySelector("textarea"); + const send = pop.querySelector("[data-send]"); + ta.value = ui.askDraft || ""; + ta.addEventListener("input", () => { + ui.askDraft = ta.value; + }); + pop.querySelector(".chips").addEventListener("click", (e) => { + const chip = e.target.closest(".chip"); + if (!chip) return; + ta.value = chip.textContent; + ui.askDraft = ta.value; + ta.focus(); + }); + const submit = async () => { + const text = ta.value.trim(); + if (!text) { + ta.focus(); + return; + } + // The question is about the drawing on screen now. If another one opens before it goes + // out, Copilot would get the wrong drawing, so it does not go out. + const drawingId = ui.doc.id; + const checkSameDrawing = () => { + if (ui.doc.id !== drawingId) throw new Error("Another drawing opened, so your question was not sent. It is still in Ask, so you can send it about this one."); + }; + send.disabled = true; + send.textContent = "Sending"; + try { + const saved = await ui.sync.flush(true); + checkSameDrawing(); + if (!saved) { + throw new Error("Your latest changes are not saved yet, so Copilot would not see them. Send again once the drawing is saved."); + } + const png = ed.elements.length ? await blobToBase64(await ed.exportPNG(2, 2000)) : ""; + checkSameDrawing(); + await ui.sync.post("ask", { drawingId, text, png }); + ui.askDraft = ""; + close(); + ui.toast("Sent to Copilot. The answer shows up in the chat."); + } catch (err) { + send.disabled = false; + send.textContent = "Send"; + ui.toast(err.message, { error: true }); + } + }; + send.addEventListener("click", submit); + ta.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + submit(); + } + }); + setTimeout(() => ta.focus(), 0); + }, { className: "ask", align: "end", label: "Ask Copilot about this drawing" }); + } + + function openThemeMenu() { + ui.togglePopover($("theme-button"), (pop, close) => { + const cur = ui.themeMode(); + pop.innerHTML = '' + THEME_CHOICES.map(([mode, iconName, label]) => + ``, + ).join(""); + pop.addEventListener("click", (e) => { + const b = e.target.closest("[data-mode]"); + if (!b) return; + close(); + ui.setThemeMode(b.dataset.mode); + }); + pop.querySelector('[aria-checked="true"]')?.focus(); + }, { className: "menu", role: "menu", align: "end", label: "Theme" }); + } + + function syncThemeButton() { + const [, iconName, label] = THEME_CHOICES.find(([mode]) => mode === ui.themeMode()) || THEME_CHOICES[0]; + const b = $("theme-button"); + b.innerHTML = icon(iconName); + b.title = `Theme: ${label}`; + b.setAttribute("aria-label", `Theme: ${label}`); + } + + function toggleHelp() { + ui.togglePopover($("help-button"), (pop) => { + const keys = (k) => (Array.isArray(k) ? k.map((s) => `${esc(s)}`).join(" ") : esc(k)); + pop.innerHTML = `

Shortcuts

${HELP.map(([k, d]) => `
${keys(k)}
${esc(d)}
`).join("")}
`; + }, { className: "help", placement: "above", label: "Keyboard shortcuts" }); + } + + $("doc-button").addEventListener("click", openDrawingsMenu); + $("theme-button").addEventListener("click", openThemeMenu); + $("export-button").addEventListener("click", openExportMenu); + $("ask-button").addEventListener("click", openAsk); + $("help-button").addEventListener("click", toggleHelp); + ui.toggleHelp = toggleHelp; + ui.startRename = startRename; + ui.syncThemeButton = syncThemeButton; +} diff --git a/extensions/draw/public/pointer.js b/extensions/draw/public/pointer.js new file mode 100644 index 000000000..d85b26abf --- /dev/null +++ b/extensions/draw/public/pointer.js @@ -0,0 +1,496 @@ +// Pointer input: select, move, resize, connect, create, draw, pan and zoom. +import { isShape, snap, clamp, newId, idPrefix, normalizeElement, DEFAULT_SIZES, SHAPE_TYPES, MAX_SIDE, MAX_PEN_POINTS } from "/lib/model.mjs"; +import { + hitTest, shapeAt, pointInShape, elementBounds, unionBounds, boxContains, arrowGeometry, lineHeight, fontSizeOf, fitStroke, +} from "/lib/geometry.mjs"; +import { translateElement } from "./editor.js"; + +const DRAG = 3; +const DOUBLE_MS = 400; +const r2 = (v) => Math.round(v * 100) / 100; + +function snapAxis(pos, size, cands, th) { + let best = null; + for (const off of [0, size / 2, size]) { + for (const c of cands) { + const d = c.v - (pos + off); + if (Math.abs(d) <= th && (!best || Math.abs(d) < Math.abs(best.d))) best = { d, v: c.v }; + } + } + return best; +} + +function guideLine(axis, v, box, cands) { + const all = unionBounds([box, ...cands.filter((c) => Math.abs(c.v - v) < 0.5).map((c) => c.b)]); + return axis === "x" + ? { x1: v, y1: all.y - 8, x2: v, y2: all.y + all.h + 8 } + : { x1: all.x - 8, y1: v, x2: all.x + all.w + 8, y2: v }; +} + +export function attachPointer(ed) { + const svg = ed.svg; + let it = null; + let lastClick = null; + let cursor = ""; + + const local = (e) => { + const r = ed.stage.getBoundingClientRect(); + return { x: e.clientX - r.left, y: e.clientY - r.top }; + }; + const tol = () => 4 / ed.view.zoom; + const grid = (v, e) => (e.altKey ? r2(v) : snap(v)); + + function setCursor(c) { + if (c !== cursor) svg.style.cursor = cursor = c; + } + + function cursorFor(h, hit) { + if (it) { + if (it.kind === "pan") return "grabbing"; + if (it.kind === "move") return "move"; + if (it.kind === "resize") return it.cursor; + if (it.kind === "marquee" || it.kind === "dbl") return "default"; + return it.kind === "text" ? "text" : "crosshair"; + } + if (ed.spaceDown || ed.tool === "hand") return "grab"; + if (ed.tool === "text") return "text"; + if (ed.tool !== "select") return "crosshair"; + if (h) { + if (h.kind === "resize") return h.which === "nw" || h.which === "se" ? "nwse-resize" : "nesw-resize"; + return h.kind === "connect" ? "pointer" : "grab"; + } + return hit ? "move" : "default"; + } + + function hoverAt(s, p) { + if (ed.tool !== "select" || ed.editing || ed.spaceDown) { + setHover(null, null); + setCursor(cursorFor(null, null)); + return; + } + const h = ed.handleAt(s.x, s.y); + let hover = null; + let hit = null; + if (h) { + hover = h.kind === "connect" ? h.id : ed.hover; + } else { + hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure); + if (hit && isShape(hit)) hover = hit.id; + else if (!hit && ed.hover) { + const cur = ed.byId.get(ed.hover); + if (cur && pointInShape(cur, p.x, p.y, 34 / ed.view.zoom)) hover = cur.id; + } + } + setHover(hover, h ? `${h.kind}:${h.id}:${h.which}` : null); + setCursor(cursorFor(h, hit)); + } + + function setHover(id, handle) { + if (id === ed.hover && handle === ed.hoverHandle) return; + ed.hover = id; + ed.hoverHandle = handle; + ed.requestRender(); + } + + ed.refreshCursor = () => setCursor(cursorFor(null, null)); + + function begin(next, e) { + it = next; + try { + svg.setPointerCapture(e.pointerId); + } catch { + // The pointer may already be gone. + } + setHover(it.kind === "connect" ? ed.hover : null, null); + ed.beginInteraction(it); + setCursor(cursorFor(null, null)); + } + + function down(e) { + if (it || (e.button !== 0 && e.button !== 1)) return; + e.preventDefault(); + if (ed.editing) ed.labels.commit({ refocus: false }); + ed.focusCanvas(); + const s = local(e); + const p = ed.toWorld(e.clientX, e.clientY); + ed.lastPointer = p; + const base = { pointerId: e.pointerId, sx: s.x, sy: s.y, start: p, moved: false }; + if (e.button === 1 || ed.spaceDown || ed.tool === "hand") return begin({ ...base, kind: "pan", view: { ...ed.view } }, e); + + const now = performance.now(); + const dbl = !!lastClick && now - lastClick.t < DOUBLE_MS && Math.hypot(s.x - lastClick.x, s.y - lastClick.y) < 6; + lastClick = dbl ? null : { t: now, x: s.x, y: s.y }; + const tool = ed.tool; + + if (tool === "select") { + const h = ed.handleAt(s.x, s.y); + if (h) { + lastClick = null; + return beginHandle(h, base, e); + } + const hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure); + if (dbl) return begin({ ...base, kind: "dbl", hit: hit ? hit.id : null }, e); + if (hit) { + if (e.shiftKey) { + ed.select([hit.id], { toggle: true }); + if (!ed.selection.has(hit.id)) return; + } else if (!ed.selection.has(hit.id)) { + ed.select([hit.id]); + } + return begin({ ...base, kind: "move", hit: hit.id, shift: e.shiftKey }, e); + } + if (!e.shiftKey) ed.select([]); + return begin({ ...base, kind: "marquee", baseSel: e.shiftKey ? [...ed.selection] : [] }, e); + } + if (tool === "text") return begin({ ...base, kind: "text" }, e); + if (tool === "pen") { + begin({ ...base, kind: "pen", points: [[r2(p.x), r2(p.y)]] }, e); + ed.preview = { points: it.points, color: ed.styles.pen.color, width: ed.styles.pen.width }; + return; + } + if (tool === "arrow") { + const from = e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol()); + return begin({ ...base, kind: "arrow", from: from ? from.id : null, x1: grid(p.x, e), y1: grid(p.y, e) }, e); + } + if (SHAPE_TYPES.includes(tool)) return begin({ ...base, kind: "create", type: tool }, e); + } + + function beginHandle(h, base, e) { + const el = ed.byId.get(h.id); + if (!el) return; + if (h.kind === "resize") { + const cursorName = h.which === "nw" || h.which === "se" ? "nwse-resize" : "nesw-resize"; + return begin({ ...base, kind: "resize", which: h.which, orig: el, cursor: cursorName }, e); + } + if (h.kind === "end") return begin({ ...base, kind: "endpoint", which: h.which, orig: el }, e); + return begin({ ...base, kind: "connect", src: el.id, dir: h.which, arrowId: null }, e); + } + + function move(e) { + const s = local(e); + const p = ed.toWorld(e.clientX, e.clientY); + ed.lastPointer = p; + if (!it) return hoverAt(s, p); + if (e.pointerId !== it.pointerId) return; + if (it.kind === "pen") return drawPen(e); + if (!it.moved && Math.hypot(s.x - it.sx, s.y - it.sy) < DRAG) return; + it.moved = true; + if (it.kind === "pan") { + const z = it.view.zoom; + ed.setView({ zoom: z, x: it.view.x - (s.x - it.sx) / z, y: it.view.y - (s.y - it.sy) / z }); + return; + } + const fn = { move: dragMove, marquee: dragMarquee, resize: dragResize, endpoint: dragEndpoint, connect: dragConnect, arrow: dragArrow, create: dragCreate }[it.kind]; + if (fn) fn(p, e); + ed.requestRender(); + } + + // How far a pen stroke may stray from what was drawn when it is simplified. + const penTolerance = () => 0.6 / ed.view.zoom; + + function drawPen(e) { + const events = e.getCoalescedEvents ? e.getCoalescedEvents() : []; + const min = 0.75 / ed.view.zoom; + for (const ev of events.length ? events : [e]) { + const q = ed.toWorld(ev.clientX, ev.clientY); + const last = it.points[it.points.length - 1]; + if (Math.hypot(q.x - last[0], q.y - last[1]) >= min) it.points.push([r2(q.x), r2(q.y)]); + } + // A long stroke is simplified as it grows, so it never holds much more than a saved one can. + if (it.points.length >= 2 * MAX_PEN_POINTS) { + it.points = fitStroke(it.points, penTolerance()); + if (ed.preview) ed.preview.points = it.points; + } + it.moved = true; + ed.requestRender(); + } + + function dragMove(p, e) { + if (!it.orig) { + it.orig = ed.elements; + it.ids = new Set(ed.selection); + const moving = it.orig.filter((el) => it.ids.has(el.id)); + const boxes = (list) => list.map((el) => elementBounds(el, ed.byId, ed.measure)); + it.box = unionBounds(boxes(moving.filter((el) => el.type !== "arrow"))) || unionBounds(boxes(moving)); + it.xs = []; + it.ys = []; + for (const el of it.orig) { + if (it.ids.has(el.id) || !(isShape(el) || el.type === "text")) continue; + const b = elementBounds(el, ed.byId, ed.measure); + for (const v of [b.x, b.x + b.w / 2, b.x + b.w]) it.xs.push({ v, b }); + for (const v of [b.y, b.y + b.h / 2, b.y + b.h]) it.ys.push({ v, b }); + } + } + let dx = p.x - it.start.x; + let dy = p.y - it.start.y; + let sx = null; + let sy = null; + const b = it.box; + if (b && !e.altKey) { + const th = 6 / ed.view.zoom; + sx = snapAxis(b.x + dx, b.w, it.xs, th); + sy = snapAxis(b.y + dy, b.h, it.ys, th); + dx = sx ? dx + sx.d : snap(b.x + dx) - b.x; + dy = sy ? dy + sy.d : snap(b.y + dy) - b.y; + } + if (e.shiftKey) { + if (Math.abs(p.x - it.start.x) >= Math.abs(p.y - it.start.y)) { dy = 0; sy = null; } else { dx = 0; sx = null; } + } + ed.guides = []; + if (b) { + const nb = { x: b.x + dx, y: b.y + dy, w: b.w, h: b.h }; + if (sx) ed.guides.push(guideLine("x", sx.v, nb, it.xs)); + if (sy) ed.guides.push(guideLine("y", sy.v, nb, it.ys)); + } + ed.setElements(it.orig.map((el) => (it.ids.has(el.id) ? translateElement(el, dx, dy) : el))); + } + + function dragMarquee(p) { + const m = { + x: Math.min(it.start.x, p.x), y: Math.min(it.start.y, p.y), + w: Math.abs(p.x - it.start.x), h: Math.abs(p.y - it.start.y), + }; + ed.marquee = m; + const inside = ed.elements.filter((el) => { + const b = elementBounds(el, ed.byId, ed.measure); + return b && boxContains(m, b); + }); + ed.select([...it.baseSel, ...inside.map((el) => el.id)]); + } + + function dragResize(p, e) { + const s = it.orig; + const west = it.which.includes("w"); + const north = it.which.includes("n"); + const fx = west ? s.x + s.w : s.x; + const fy = north ? s.y + s.h : s.y; + const px = grid(p.x, e); + const py = grid(p.y, e); + let w = clamp(west ? fx - px : px - fx, 20, MAX_SIDE); + let h = clamp(north ? fy - py : py - fy, 20, MAX_SIDE); + if (e.shiftKey) { + // Keeps the proportions, up to the size limit. + const k = Math.min(Math.max(w / s.w, h / s.h), MAX_SIDE / Math.max(s.w, s.h)); + w = Math.max(20, s.w * k); + h = Math.max(20, s.h * k); + } + ed.replaceElement({ ...s, x: r2(west ? fx - w : fx), y: r2(north ? fy - h : fy), w: r2(w), h: r2(h) }); + } + + function dragEndpoint(p, e) { + const a = it.orig; + const isStart = it.which === "start"; + const other = isStart ? a.to : a.from; + const target = e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol(), other); + ed.target = target ? target.id : null; + const x = target ? r2(p.x) : grid(p.x, e); + const y = target ? r2(p.y) : grid(p.y, e); + const id = target ? target.id : null; + ed.replaceElement(isStart ? { ...a, from: id, x1: x, y1: y } : { ...a, to: id, x2: x, y2: y }); + } + + // Shared by the connect handle and the arrow tool: a live arrow from `from` to the pointer. + function liveArrow(p, e, from, extra = {}) { + const src = from ? ed.byId.get(from) : null; + const inside = src && pointInShape(src, p.x, p.y, 2 / ed.view.zoom); + const target = inside || e.altKey ? null : shapeAt(ed.elements, p.x, p.y, tol(), from); + ed.target = target ? target.id : null; + if (inside) { + if (it.arrowId && ed.byId.has(it.arrowId)) ed.setElements(ed.elements.filter((el) => el.id !== it.arrowId)); + return; + } + if (!it.arrowId) it.arrowId = newId("a"); + const x2 = target ? r2(p.x) : grid(p.x, e); + const y2 = target ? r2(p.y) : grid(p.y, e); + ed.upsertElement(ed.newArrow({ id: it.arrowId, from, to: target ? target.id : null, x2, y2, ...extra })); + } + + function dragConnect(p, e) { + liveArrow(p, e, it.src); + } + + function dragArrow(p, e) { + liveArrow(p, e, it.from, { x1: it.x1, y1: it.y1 }); + } + + function dragCreate(p, e) { + const x0 = grid(it.start.x, e); + const y0 = grid(it.start.y, e); + const x1 = grid(p.x, e); + const y1 = grid(p.y, e); + let w = Math.abs(x1 - x0); + let h = Math.abs(y1 - y0); + if (e.shiftKey) w = h = Math.max(w, h); + if (!it.id) it.id = newId(idPrefix(it.type)); + ed.upsertElement(ed.newShape(it.type, { + id: it.id, x: x1 < x0 ? x0 - w : x0, y: y1 < y0 ? y0 - h : y0, w: Math.max(10, w), h: Math.max(10, h), + })); + } + + function arrowLength(id) { + const a = ed.byId.get(id); + const g = a && arrowGeometry(a, ed.byId); + return g ? Math.hypot(g.end.x - g.start.x, g.end.y - g.start.y) : 0; + } + + function createText(p) { + const before = ed.elements; + const lh = lineHeight(fontSizeOf(ed.styles.text)); + const t = ed.newText({ x: snap(p.x), y: snap(p.y - lh / 2) }); + ed.setElements([...ed.elements, t], { record: false }); + ed.select([t.id]); + ed.setTool("select"); + ed.labels.open(t.id, { before }); + } + + function editOrCreateText(p) { + const hit = hitTest(ed.elements, p.x, p.y, tol(), ed.measure); + if (hit && hit.type !== "pen") { + ed.setTool("select"); + ed.select([hit.id]); + ed.labels.open(hit.id); + } else { + createText(p); + } + } + + function up(e) { + if (it && e.pointerId === it.pointerId) finish(e, false); + } + + function finish(e, cancelled) { + const cur = it; + it = null; + try { + svg.releasePointerCapture(cur.pointerId); + } catch { + // Already released. + } + if (cancelled) { + ed.endInteraction({ revert: cur.kind !== "pan" && cur.kind !== "marquee" }); + ed.refreshCursor(); + return; + } + const start = ed.changeStart; + const p = cur.moved ? ed.lastPointer || cur.start : cur.start; + let after = null; + + if (cur.kind === "move") { + if (!cur.moved && !cur.shift && ed.selection.size > 1) ed.select([cur.hit]); + } else if (cur.kind === "endpoint") { + if (arrowLength(cur.orig.id) < 4) ed.replaceElement(cur.orig); + } else if (cur.kind === "dbl") { + const hit = cur.hit ? ed.byId.get(cur.hit) : null; + after = () => { + if (!hit) return createText(cur.start); + ed.select([hit.id]); + if (hit.type !== "pen") ed.labels.open(hit.id); + }; + } else if (cur.kind === "text") { + after = () => editOrCreateText(cur.start); + } else if (cur.kind === "pen") { + const el = normalizeElement({ id: newId("p"), type: "pen", points: fitStroke(cur.points, penTolerance()), color: ed.styles.pen.color, width: ed.styles.pen.width }); + if (el) ed.setElements([...ed.elements, el]); + } else if (cur.kind === "connect") { + if (!cur.moved) { + after = () => ed.addConnected(cur.src, cur.dir); + } else if (cur.arrowId && ed.byId.has(cur.arrowId)) { + const arrow = ed.byId.get(cur.arrowId); + const src = ed.byId.get(cur.src); + const ddx = p.x - (src.x + src.w / 2); + const ddy = p.y - (src.y + src.h / 2); + ed.lastDirection = Math.abs(ddx) >= Math.abs(ddy) ? (ddx >= 0 ? "right" : "left") : (ddy >= 0 ? "down" : "up"); + if (arrow.to) { + after = () => ed.select([arrow.id]); + } else { + const shape = ed.newShape(src.type, { + ...src, id: newId(idPrefix(src.type)), text: "", x: snap(p.x - src.w / 2), y: snap(p.y - src.h / 2), + }); + ed.setElements([...ed.elements.map((el) => (el.id === arrow.id ? { ...el, to: shape.id } : el)), shape]); + ed.endInteraction({ record: false }); + ed.select([shape.id]); + ed.labels.open(shape.id, { fresh: true, before: start }); + ed.refreshCursor(); + return; + } + } + } else if (cur.kind === "arrow") { + if (!cur.arrowId || !ed.byId.has(cur.arrowId)) { + if (!cur.moved) ed.emit("hint", "Drag to draw an arrow. Start on a shape to connect it."); + } else if (arrowLength(cur.arrowId) < 8) { + ed.setElements(ed.elements.filter((el) => el.id !== cur.arrowId)); + } else { + after = () => { + ed.select([cur.arrowId]); + ed.setTool("select"); + }; + } + } else if (cur.kind === "create") { + let el = cur.id ? ed.byId.get(cur.id) : null; + const fresh = !el || el.w < 20 || el.h < 20; + if (fresh) { + const [w, h] = DEFAULT_SIZES[cur.type]; + el = ed.newShape(cur.type, { id: cur.id || newId(idPrefix(cur.type)), x: snap(cur.start.x - w / 2), y: snap(cur.start.y - h / 2), w, h }); + ed.upsertElement(el); + } + ed.endInteraction({ record: false }); + ed.select([el.id]); + ed.setTool("select"); + ed.labels.open(el.id, { fresh, before: start }); + ed.refreshCursor(); + return; + } + ed.endInteraction(); + if (after) after(); + ed.refreshCursor(); + } + + function cancel(e) { + if (it && e.pointerId === it.pointerId) finish(e, true); + } + + function wheel(e) { + e.preventDefault(); + const s = local(e); + if (e.ctrlKey || e.metaKey) { + const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + ed.zoomAt(Math.exp(-dy * 0.002), s.x, s.y); + return; + } + let dx = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX; + let dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + if (e.shiftKey && !dx) { + dx = dy; + dy = 0; + } + ed.panBy(dx, dy); + } + + svg.addEventListener("pointerdown", down); + svg.addEventListener("pointermove", move); + svg.addEventListener("pointerup", up); + svg.addEventListener("pointercancel", cancel); + svg.addEventListener("lostpointercapture", (e) => { + if (it && e.pointerId === it.pointerId) finish(e, false); + }); + svg.addEventListener("pointerleave", () => { + if (!it) setHover(null, null); + }); + svg.addEventListener("mousedown", (e) => e.preventDefault()); + svg.addEventListener("contextmenu", (e) => e.preventDefault()); + svg.addEventListener("wheel", wheel, { passive: false }); + + ed.cancelPointer = () => { + if (!it) return; + const cur = it; + it = null; + try { + svg.releasePointerCapture(cur.pointerId); + } catch { + // Already released. + } + ed.endInteraction({ revert: cur.kind !== "pan" && cur.kind !== "marquee" }); + ed.refreshCursor(); + }; + ed.pointerActive = () => !!it; +} diff --git a/extensions/draw/public/styles.css b/extensions/draw/public/styles.css new file mode 100644 index 000000000..f734acf46 --- /dev/null +++ b/extensions/draw/public/styles.css @@ -0,0 +1,287 @@ +*, *::before, *::after { box-sizing: border-box; } +html, body { margin: 0; height: 100%; overflow: hidden; } + +.app { + --bg: var(--d-bg, #ffffff); + --ink: var(--d-ink, #1f2328); + --muted: var(--d-muted, #59636e); + --border: var(--d-border, #d1d9e0); + --accent: var(--d-accent, #0969da); + --on-accent: var(--d-on-accent, #ffffff); + --on-danger: var(--d-on-danger, #ffffff); + --hover: var(--d-hover, rgba(128, 128, 128, 0.12)); + --active: var(--d-active, rgba(128, 128, 128, 0.2)); + --accent-soft: var(--d-accent-soft, rgba(9, 105, 218, 0.12)); + --shadow: var(--d-shadow, 0 1px 2px rgba(0, 0, 0, 0.06)); + --pop-shadow: var(--d-pop-shadow, 0 8px 28px rgba(0, 0, 0, 0.16)); + --font: var(--d-font, "Segoe UI", system-ui, -apple-system, sans-serif); + --mono: var(--d-mono, ui-monospace, "Cascadia Code", Consolas, monospace); + + height: 100%; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: var(--bg); + color: var(--ink); + font: 13px/1.4 var(--font); + -webkit-font-smoothing: antialiased; + transition: opacity 120ms ease-out; +} +/* Hidden until the host's theme arrives, so a dark app doesn't flash white on open. */ +.app.booting { opacity: 0; } + +button { font: inherit; color: inherit; } +button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } +svg.icon { width: 16px; height: 16px; display: block; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; } +kbd { + font: 500 11px/1 var(--mono); + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + padding: 1px 4px; + color: var(--ink); + background: var(--bg); +} + +/* ---------- top bar ---------- */ +.bar { + height: 40px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 8px; + border-bottom: 1px solid var(--border); + min-width: 0; +} +.bar-group { display: flex; align-items: center; gap: 4px; min-width: 0; } +.bar-doc { flex: 1 1 auto; } +.bar-actions { flex: none; } +.doc-button { + display: flex; align-items: center; gap: 6px; + max-width: 240px; min-width: 0; + height: 28px; padding: 0 8px; + border: 0; border-radius: 6px; background: transparent; + font-weight: 600; cursor: pointer; +} +.doc-button:hover, .doc-button[aria-expanded="true"] { background: var(--hover); } +.doc-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chev { + flex: none; width: 6px; height: 6px; margin-top: -3px; + border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); opacity: 0.55; +} +.doc-rename { + width: 220px; height: 28px; padding: 0 8px; + font: 600 13px var(--font); color: var(--ink); + background: var(--bg); border: 1px solid var(--accent); border-radius: 6px; outline: none; +} +/* A long status gives up its room before the drawing name does. */ +.status { flex-shrink: 1000; color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.status.is-error { color: var(--d-danger, #cf222e); } +.sep { width: 1px; height: 18px; background: var(--border); margin: 0 4px; flex: none; } + +.btn { + height: 28px; padding: 0 10px; + border: 1px solid var(--border); border-radius: 6px; + background: var(--bg); font-weight: 500; font-size: 12.5px; + white-space: nowrap; cursor: pointer; +} +.btn:hover, .btn[aria-expanded="true"] { background: var(--hover); } +.btn-primary { background: var(--accent); color: var(--on-accent); border-color: transparent; } +.btn-primary:hover, .btn-primary[aria-expanded="true"] { background: var(--accent); filter: brightness(1.08); } +.btn:disabled { opacity: 0.5; cursor: default; } +.btn-danger { background: var(--d-danger, #cf222e); color: var(--on-danger); border-color: transparent; } +.btn-danger:hover { background: var(--d-danger, #cf222e); filter: brightness(1.08); } + +.icon-btn { + width: 28px; height: 28px; padding: 0; + display: grid; place-items: center; + border: 0; border-radius: 6px; background: transparent; + cursor: pointer; flex: none; +} +.icon-btn:hover:not(:disabled) { background: var(--hover); } +.icon-btn:disabled { opacity: 0.35; cursor: default; } +.icon-btn[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); } + +/* ---------- stage ---------- */ +.stage { + position: relative; + overflow: hidden; + outline: none; + touch-action: none; + user-select: none; + -webkit-user-select: none; + background-color: var(--bg); + background-image: radial-gradient(circle at 1px 1px, var(--d-dot, rgba(128, 128, 128, 0.35)) 1px, transparent 1.4px); +} +.svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; font-family: var(--font); outline: none; } +.svg:focus-visible:not(.pointer-used) { outline: 2px solid var(--accent); outline-offset: -2px; } +.sr-only { + position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; + overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; +} +.layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } +.label-editor { + position: absolute; + pointer-events: auto; + margin: 0; padding: 0; border: 0; outline: none; resize: none; overflow: hidden; + background: transparent; + white-space: pre-wrap; overflow-wrap: anywhere; + caret-color: var(--accent); +} +.label-editor.no-wrap { white-space: pre; overflow-wrap: normal; } + +.floating { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.tools { + position: absolute; left: 8px; top: 8px; + display: flex; flex-direction: column; gap: 1px; + padding: 3px; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); + max-height: calc(100% - 56px); overflow-y: auto; scrollbar-width: none; +} +.tool { position: relative; width: 30px; height: 30px; } +.tool .key { + position: absolute; right: 2px; bottom: 1px; + font: 600 8px/1 var(--mono); color: var(--muted); opacity: 0.8; + pointer-events: none; +} +.tool[aria-pressed="true"] .key { color: var(--accent); } +.tools .rule { height: 1px; margin: 3px 5px; background: var(--border); flex: none; } + +.stylebar { + position: absolute; top: 8px; left: 56px; right: 8px; + display: flex; justify-content: center; + pointer-events: none; +} +.stylebar[hidden] { display: none; } +.stylebar-inner { + pointer-events: auto; + display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 2px; + padding: 3px; max-width: 100%; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); +} +.style-group { display: flex; align-items: center; gap: 1px; } +.style-group + .style-group { border-left: 1px solid var(--border); padding-left: 3px; margin-left: 1px; } +.swatch { width: 26px; height: 26px; } +.swatch .dot { width: 14px; height: 14px; border-radius: 50%; border: 1.5px solid; } +.swatch[aria-pressed="true"] { background: transparent; box-shadow: inset 0 0 0 1.5px var(--accent); color: inherit; } +.size-btn { width: 26px; height: 26px; font: 600 11px/1 var(--font); } + +.zoom { + position: absolute; right: 8px; bottom: 8px; + display: flex; align-items: center; gap: 1px; padding: 2px; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); +} +.zoom-level { + min-width: 48px; height: 28px; padding: 0 4px; + border: 0; border-radius: 6px; background: transparent; + font: 500 12px var(--font); font-variant-numeric: tabular-nums; cursor: pointer; +} +.zoom-level:hover { background: var(--hover); } +.help-button { + position: absolute; left: 8px; bottom: 8px; + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--shadow); + width: 32px; height: 32px; +} + +.empty { + position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); + width: min(340px, calc(100% - 120px)); + text-align: center; color: var(--muted); + pointer-events: none; +} +.empty[hidden] { display: none; } +.empty p { margin: 0 0 6px; } +.empty .empty-title { color: var(--ink); font-size: 15px; font-weight: 600; margin-bottom: 10px; } +.empty b { color: var(--accent); } + +/* ---------- popovers ---------- */ +.popover { + position: fixed; z-index: 30; + min-width: 200px; max-width: min(340px, calc(100vw - 16px)); + max-height: calc(100vh - 56px); overflow-y: auto; + padding: 4px; + background: var(--bg); color: var(--ink); + border: 1px solid var(--border); border-radius: 8px; box-shadow: var(--pop-shadow); +} +.menu-label { padding: 6px 8px 4px; color: var(--muted); font-size: 11px; font-weight: 600; } +.menu-item { + width: 100%; display: flex; align-items: center; gap: 8px; + min-height: 30px; padding: 4px 8px; + border: 0; border-radius: 5px; background: transparent; + text-align: left; cursor: pointer; +} +.menu-item:hover, .menu-item:focus-visible { background: var(--hover); outline: none; } +.menu-item .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.menu-item > .icon { color: var(--muted); } +.menu-item.danger > .icon { color: inherit; } +.menu-item .meta { color: var(--muted); font-size: 11.5px; white-space: nowrap; } +.menu-item.is-current { font-weight: 600; } +.menu-item.is-current::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); flex: none; } +.menu-item.danger { color: var(--d-danger, #cf222e); } +.menu-item[aria-checked="true"] { font-weight: 600; } +.menu-item .tick { display: flex; width: 16px; flex: none; color: var(--accent); } +.menu-rule { height: 1px; margin: 4px 0; background: var(--border); } +.menu-note { padding: 6px 8px; color: var(--muted); font-size: 12px; } +.menu-actions { display: flex; justify-content: flex-end; gap: 6px; padding: 4px 4px 2px; } + +.ask { width: 340px; padding: 10px; } +.ask-title { font-weight: 600; margin: 0 0 2px; } +.ask-sub { color: var(--muted); font-size: 12px; margin: 0 0 8px; } +.ask textarea { + width: 100%; min-height: 76px; max-height: 220px; resize: vertical; + padding: 7px 8px; border: 1px solid var(--border); border-radius: 6px; + background: var(--bg); color: var(--ink); font: 13px/1.45 var(--font); +} +.ask textarea:focus { outline: none; border-color: var(--accent); } +.ask-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; } +.ask-hint { color: var(--muted); font-size: 11.5px; } +.chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; } +.chip { + height: 24px; padding: 0 8px; border: 1px solid var(--border); border-radius: 12px; + background: transparent; font-size: 12px; color: var(--muted); cursor: pointer; +} +.chip:hover { color: var(--ink); background: var(--hover); } + +.help { width: 320px; padding: 8px 10px 10px; } +.help h2 { font-size: 13px; margin: 2px 0 8px; } +.help dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 12px; margin: 0; font-size: 12px; } +.help dt { white-space: nowrap; } +.help dd { margin: 0; color: var(--muted); } + +/* ---------- toasts ---------- */ +.toasts { + position: fixed; left: 50%; bottom: 52px; transform: translateX(-50%); + display: flex; flex-direction: column; align-items: center; gap: 6px; + z-index: 40; pointer-events: none; +} +.toast { + pointer-events: auto; + display: flex; align-items: center; gap: 10px; + max-width: min(420px, calc(100vw - 24px)); + padding: 7px 8px 7px 12px; + background: var(--ink); color: var(--bg); + border-radius: 8px; box-shadow: var(--pop-shadow); + font-size: 12.5px; + animation: toast-in 140ms ease-out; +} +.toast.is-error { background: var(--d-danger, #cf222e); color: var(--on-danger); } +.toast button { + border: 0; background: transparent; color: inherit; font-weight: 600; text-decoration: underline; + cursor: pointer; padding: 2px 4px; +} +@keyframes toast-in { from { opacity: 0; transform: translateY(4px); } } + +@media (max-width: 520px) { + .status { display: none; } + .doc-button { max-width: 150px; } +} +@media (prefers-reduced-motion: reduce) { + .toast { animation: none; } +} diff --git a/extensions/draw/public/sync.js b/extensions/draw/public/sync.js new file mode 100644 index 000000000..4c3e4736b --- /dev/null +++ b/extensions/draw/public/sync.js @@ -0,0 +1,486 @@ +// Keeps the editor and the extension's copy of the drawing in sync. +// Local edits are sent as small element-level ops, and the server sends each change as ops too. +// Remote (agent) edits are merged on top of whatever the user has not saved yet. +import { applyOps, diffOps, mergeOps } from "/lib/model.mjs"; + +// How many changes can wait for the one before them (see drainOps) before the panel gives up on +// them and fetches the whole drawing again. +const MAX_EARLY = 200; + +// Browsers only send keepalive requests (the kind that still goes out once the page is gone) +// while their bodies add up to at most 64 KiB (see flushBeacon). +const KEEPALIVE_BYTES = 64 * 1024; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export class Sync { + constructor({ instanceId, token, editor, handlers }) { + this.instanceId = instanceId; + this.token = token; + this.editor = editor; + this.handlers = handlers; + this.clientId = `c${Math.random().toString(36).slice(2, 10)}`; + this.drawingId = null; + this.rev = 0; + this.synced = new Map(); + this.inflight = false; + // Every change sent gets the next number, so the server can tell an old one that arrives late. + this.seq = 0; + // The ops of the change on its way, if any, and the drawing they are for. + this.sending = null; + this.timer = 0; + this.deferred = null; + // Changes from the server that came before the one they follow (see drainOps). + this.early = []; + // Set when a change was missed, so the whole drawing has to be fetched again. + this.behind = false; + this.resyncing = false; + // The agent's newest selection, while the editor cannot show it yet (see showSelection). + this.pendingSelection = null; + this.failures = 0; + this.retryTimer = 0; + // Why the server refused our last changes, and why the extension cannot write this drawing + // to disk (it still has the changes in memory). Both show as errors until they clear. + this.refusal = null; + this.diskError = null; + // Set when the server says this drawing was deleted, so there is nothing left to save it to. + this.gone = false; + // Drawings this panel is moving to, each waiting for the edits on the current one to be saved. + this.switching = new Set(); + this.source = null; + editor.on("change", () => this.schedule()); + editor.on("idle", () => this.onIdle()); + window.addEventListener("pagehide", () => this.flushBeacon()); + } + + url(route, extra = "") { + return `/api/${route}?i=${encodeURIComponent(this.instanceId)}&t=${encodeURIComponent(this.token)}${extra}`; + } + + async post(route, body) { + const res = await fetch(this.url(route), { + method: "POST", + headers: { "Content-Type": "text/plain;charset=utf-8" }, + body: JSON.stringify({ clientId: this.clientId, ...body }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const err = new Error(data.error || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + return data; + } + + async loadState() { + const res = await fetch(this.url("state"), { cache: "no-store" }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `Could not load the drawing (${res.status})`); + return data; + } + + setDoc(doc) { + this.drawingId = doc.id; + this.rev = doc.rev; + this.synced = new Map(doc.elements.map((e) => [e.id, e])); + this.deferred = null; + this.early = []; + this.behind = false; + this.pendingSelection = null; + this.gone = false; + } + + connect() { + const source = new EventSource(this.url("events", `&c=${this.clientId}`)); + this.source = source; + const on = (name, fn) => source.addEventListener(name, (ev) => { + let data; + try { + data = JSON.parse(ev.data); + } catch { + return; + } + fn(data); + }); + source.addEventListener("open", () => { + this.handlers.connection?.(true); + // "Reconnecting" covered the status line, so bring back an error that still applies. + if (this.refusal) this.handlers.status?.("error", this.refusal); + else if (this.diskError) this.settled(); + // Changes made before the server knew about this connection (since the drawing was + // loaded, or while the connection was down) never come as events. + this.resync(); + }); + source.addEventListener("error", () => this.handlers.connection?.(false)); + on("ops", (ev) => this.onOps(ev)); + on("list", ({ drawings }) => this.handlers.list?.(drawings)); + on("switch", ({ drawing, switchId }) => this.switchTo(drawing, { switchId })); + on("export", (req) => this.handlers.exportRequest?.(req)); + on("select", ({ drawingId, ids }) => { + if (drawingId === this.drawingId) this.showSelection(ids); + for (const t of this.switching) if (t.drawing.id === drawingId) t.selection = ids; + }); + on("settings", ({ settings, origin }) => { + if (origin !== this.clientId) this.handlers.settings?.(settings); + }); + on("save", ({ drawingId, error }) => { + if (drawingId === this.drawingId) this.setDiskError(error); + for (const t of this.switching) if (t.drawing.id === drawingId) t.drawing = { ...t.drawing, saveError: error }; + }); + } + + // Fetches the whole drawing again: each time the events connect, and when a change turns out to + // be missing (see drainOps). Changes that come meanwhile wait in `early`. + async resync() { + if (this.resyncing) { + // This one may have asked before the change that was missed, so ask again after it. + this.behind = true; + return; + } + this.resyncing = true; + this.behind = false; + let state = null; + try { + state = await this.loadState(); + } catch { + // The next change or reconnect tries again. + this.behind = true; + } finally { + this.resyncing = false; + } + if (!state) return; + this.handlers.list?.(state.drawings); + if (state.settings) this.handlers.settings?.(state.settings); + if (state.drawing.id !== this.drawingId) { + this.switchTo(state.drawing); + } else { + this.onRemote(state.drawing); + this.setDiskError(state.drawing.saveError); + this.drainOps({ refetch: false }); + } + } + + // A change from the server: the ops that turn the drawing at `baseRev` into the one at `rev`. + onOps(ev) { + for (const t of this.switching) { + if (t.drawing.id !== ev.drawingId || ev.rev <= t.drawing.rev) continue; + if (ev.baseRev === t.drawing.rev) { + t.drawing = { ...t.drawing, rev: ev.rev, elements: ev.ops ? mergeOps(t.drawing.elements, ev.ops) : t.drawing.elements }; + } else { + t.behind = true; + } + } + if (ev.drawingId !== this.drawingId) return; + if (this.early.length >= MAX_EARLY) { + this.early = []; + this.behind = true; + } + this.early.push(ev); + this.drainOps(); + } + + // Applies the changes that follow on from the newest copy of the drawing this panel has: the one + // waiting to be shown, or else the one it last loaded or saved. While our own save is on its way, + // a change made after it can come first, and waits for its reply (our change is the one it + // follows). Any other gap means a change never came (the panel was on another drawing for a + // moment, say), so the whole drawing is fetched again. Right after that fetch (`refetch` false), + // a change that still does not follow on can never apply, so it is dropped instead, and the next + // change that does not follow on fetches the drawing again. + drainOps({ refetch = true } = {}) { + const queue = this.early; + while (queue.length) { + const ev = queue[0]; + const base = this.deferred || { rev: this.rev, elements: null }; + if (ev.drawingId !== this.drawingId || ev.rev <= base.rev) { + queue.shift(); + continue; + } + if (ev.baseRev !== base.rev) break; + queue.shift(); + const elements = base.elements || [...this.synced.values()]; + this.onRemote({ id: ev.drawingId, rev: ev.rev, elements: ev.ops ? mergeOps(elements, ev.ops) : elements }); + } + if (this.inflight) return; + if (queue.length) { + if (refetch) this.behind = true; + else queue.length = 0; + } + if (this.behind) this.resync(); + } + + onRemote(drawing) { + if (!drawing) return; + // A drawing this panel is about to show: keep the newest copy of it for when it does. + for (const t of this.switching) if (t.drawing.id === drawing.id && drawing.rev > t.drawing.rev) t.drawing = drawing; + if (drawing.id !== this.drawingId || drawing.rev <= this.rev) return; + if (this.inflight || this.editor.busy) { + if (!this.deferred || drawing.rev > this.deferred.rev) this.deferred = drawing; + return; + } + this.rebase(drawing); + } + + // Replaces our base with the remote doc and re-applies unsaved local changes on top. `record` + // is false when the doc differs only because the server tidied up what we sent. + rebase(drawing, { record = true } = {}) { + const pending = diffOps(this.synced, this.editor.elements); + this.synced = new Map(drawing.elements.map((e) => [e.id, e])); + this.rev = drawing.rev; + this.deferred = null; + // No element cap here. Going over it makes the server refuse the save and say why, which is + // better than the user's newest elements quietly disappearing. + const next = pending ? applyOps(drawing.elements, pending, Infinity) : drawing.elements; + this.editor.applyRemote(next, { record }); + if (pending) this.schedule(); + } + + // Shows another drawing. Edits on screen that are not saved yet would be lost, so when they + // cannot be saved first, the panel stays on this drawing, tells the server so, and resolves + // false, and the unsaved handler lets the user discard them. `discard` skips that check. + // A deleted drawing has nowhere left to save to, so it never holds the panel. `switchId` comes + // with a drawing the agent opened, and the server hears which way it went (see openDrawing in + // server.mjs). + async switchTo(drawing, { discard = false, switchId = null } = {}) { + // Showing the drawing that is already on screen loses nothing, so it is only an update to it. + if (drawing.id === this.drawingId) { + this.onRemote(drawing); + this.confirmSwitch(switchId); + return true; + } + // The server already treats the other drawing as shown, so while the flush below waits, it + // sends that drawing's changes, save status and selection. They are kept here until then. + const target = { drawing, selection: null, behind: false }; + this.switching.add(target); + let ready; + try { + ready = discard || (await this.flush(true)) || this.gone; + } finally { + this.switching.delete(target); + } + if (!ready) { + // The server has already moved this panel to the other drawing, so move it back. Changes to + // this drawing did not come to the panel in the meantime, so it takes the copy in the reply. + this.post("drawings", { action: "open", id: this.drawingId, switchId }) + .then((res) => { + if (res.drawing) this.onRemote(res.drawing); + this.drainOps(); + this.sendSelection([...this.editor.selection]); + }) + .catch(() => {}); + this.handlers.unsaved?.(target.drawing); + return false; + } + drawing = target.drawing; + // Another switch can show this drawing first. Then this copy is only an update to it. + if (drawing.id === this.drawingId) { + this.onRemote(drawing); + this.confirmSwitch(switchId); + return true; + } + this.setDoc(drawing); + this.handlers.switched?.(drawing); + if (target.selection) this.showSelection(target.selection); + // Messages about the last drawing do not apply to this one. + this.refusal = null; + this.diskError = null; + this.setDiskError(drawing.saveError); + if (!this.diskError) this.settled(); + // A change to it came while the panel waited, and one before it never did. + if (target.behind) this.resync(); + this.confirmSwitch(switchId); + return true; + } + + confirmSwitch(switchId) { + if (switchId) this.post("switched", { switchId }).catch(() => {}); + } + + // The extension has all our changes, so show "saved", unless it cannot write them to disk. + settled() { + if (this.diskError) this.handlers.status?.("error", `Not saved to disk: ${this.diskError}`); + else this.handlers.status?.("saved"); + } + + setDiskError(error) { + const was = this.diskError; + this.diskError = error || null; + if (this.diskError === was) return; + if (this.diskError) this.handlers.problem?.(`This drawing could not be saved to disk: ${this.diskError}. Draw will keep trying.`); + if (this.diskError || (!this.inflight && !this.dirty)) this.settled(); + } + + onIdle() { + // Once the handler that ended the edit or drag is done, since it may start the next one right + // away (Tab opens the next step's label). Then what waits keeps waiting. + queueMicrotask(() => this.applyDeferred()); + this.schedule(); + } + + // Shows a selection the agent made. While the user drags or types, or while an agent change + // waits for a save to finish (it may add the elements named here), the newest one waits for + // applyDeferred. + showSelection(ids) { + this.pendingSelection = { ids, before: [...this.editor.selection] }; + this.applyDeferred(); + } + + // Applies what the server sent while the editor could not take it: the newest remote doc, and + // then the agent's newest selection, unless the user picked something else since it came. + // Their newer choice stands then, and the server already has it. Elements that are gone do + // not count as a different choice, since removing an element also drops it from the selection. + applyDeferred() { + if (this.inflight || this.editor.busy) return; + if (this.deferred) { + const d = this.deferred; + this.deferred = null; + if (d.rev > this.rev) this.rebase(d); + } + const sel = this.pendingSelection; + if (!sel) return; + this.pendingSelection = null; + const now = this.editor.selection; + const before = sel.before.filter((id) => this.editor.byId.has(id)); + if (before.length === now.size && before.every((id) => now.has(id))) this.handlers.select?.(sel.ids); + } + + schedule(delay = 60) { + if (this.timer) return; + this.timer = setTimeout(() => { + this.timer = 0; + this.flush(); + }, delay); + } + + get dirty() { + return !!this.drawingId && !!diffOps(this.synced, this.editor.elements); + } + + // Sends the edits the extension does not have yet. Resolves true once it has everything the + // editor shows, false while something is still unsaved. `force` waits for a request that is + // already out and sends even in the middle of a gesture, for callers that need the saved copy + // to match the screen. + async flush(force = false) { + if (this.inflight) { + if (!force) return false; + while (this.inflight) await sleep(30); + } + if (!this.drawingId) return true; + if (!force && this.editor.busy) return false; + const sent = this.editor.elements; + const ops = diffOps(this.synced, sent); + if (!ops) { + this.refusal = null; + this.settled(); + return true; + } + this.inflight = true; + this.handlers.status?.("saving"); + const drawingId = this.drawingId; + this.sending = { drawingId, ops }; + let retry = false; + let refused = false; + try { + const res = await this.post("ops", { drawingId, baseRev: this.rev, seq: ++this.seq, ops }); + this.failures = 0; + this.refusal = null; + if (drawingId === this.drawingId) { + // "ignored" means the server already had newer changes from this page, so these were + // not applied. They are still on screen, so rebasing sends them again. + if (!res.ignored) this.synced = new Map(sent.map((e) => [e.id, e])); + this.rev = res.rev; + this.inflight = false; + // "cleaned" means the server saved our changes in a tidier form than we sent (a size + // past the limit, say), so the screen takes that form, without an undo step of its own. + if (res.drawing) this.rebase(res.drawing, { record: !res.cleaned }); + } + } catch (err) { + if (err.status === 404) { + refused = true; + this.gone = drawingId === this.drawingId; + this.handlers.status?.("error", "This drawing was deleted."); + } else if (err.status === 400 || err.status === 413) { + // Sending the same changes again would fail the same way, so the next edit tries again + // (undoing, for example, can bring the drawing back under a limit). + refused = true; + const msg = `Not saved: ${err.message}`; + this.handlers.status?.("error", msg); + if (msg !== this.refusal) this.handlers.problem?.(msg); + this.refusal = msg; + } else { + this.failures += 1; + retry = true; + this.handlers.status?.("error", this.failures <= 6 ? "Not saved yet, retrying" : "Could not save. Check that Copilot is running."); + } + } finally { + this.inflight = false; + this.sending = null; + } + // Changes that came before the reply, and waited for it. + this.drainOps(); + if (retry) { + clearTimeout(this.retryTimer); + this.retryTimer = setTimeout(() => this.schedule(0), Math.min(8000, 400 * 2 ** this.failures)); + return false; + } + if (drawingId !== this.drawingId) { + // The panel moved to another drawing while this was out. Edits made there since then + // could not be sent during that time, so send them now. + if (this.dirty) this.schedule(); + return !this.dirty; + } + this.applyDeferred(); + if (refused) return false; + if (diffOps(this.synced, this.editor.elements)) { + // Edits made while the request was out. A forced flush sends them now too. + if (force) return this.flush(true); + this.schedule(); + return false; + } + this.settled(); + return true; + } + + // Best effort save when the iframe goes away. A change still on its way can reach the server + // before or after this one. If it comes after, the server drops it (its number is lower). If + // it comes first, this one has to undo whatever the editor no longer shows, so it also sets + // every element that change touched to what the editor shows now. + flushBeacon() { + if (!this.drawingId) return; + const elements = this.editor.elements; + const ops = diffOps(this.synced, elements) || { upserts: [], deletes: [] }; + const out = this.sending?.drawingId === this.drawingId ? this.sending.ops : null; + if (out) { + const now = new Map(elements.map((e) => [e.id, e])); + const listed = new Set([...ops.upserts.map((e) => e.id), ...ops.deletes]); + for (const id of [...out.upserts.map((e) => e.id), ...out.deletes]) { + if (listed.has(id)) continue; + listed.add(id); + if (now.has(id)) ops.upserts.push(now.get(id)); + else ops.deletes.push(id); + } + // Its order, or a deleted element coming back at the end, can leave the order wrong. + if (out.order || out.deletes.some((id) => now.has(id))) ops.order = elements.map((e) => e.id); + } + if (!ops.upserts.length && !ops.deletes.length && !ops.order) return; + const body = JSON.stringify({ clientId: this.clientId, drawingId: this.drawingId, seq: ++this.seq, ops }); + // Only a keepalive request is sure to arrive once the page is gone, and past the keepalive limit + // the browser refuses it outright. Splitting it would not help, since the limit is for all of + // them together. Any one element fits (see MAX_PEN_POINTS), and a change is saved about 60 ms + // after it is made, so only a bulk change to a big drawing that was not saved yet (a drag of + // many shapes that is still going on, say) is bigger. That goes as an ordinary request, which + // can still reach the local server but is often cancelled along with the page. + const keepalive = new TextEncoder().encode(body).length <= KEEPALIVE_BYTES; + try { + fetch(this.url("ops"), { method: "POST", body, keepalive, headers: { "Content-Type": "text/plain;charset=utf-8" } }).catch(() => {}); + } catch { + // Nothing else we can do while unloading. + } + } + + sendSelection(ids) { + clearTimeout(this.selTimer); + this.selTimer = setTimeout(() => { + this.post("selection", { drawingId: this.drawingId, ids }).catch(() => {}); + }, 150); + } +} diff --git a/extensions/draw/public/theme.js b/extensions/draw/public/theme.js new file mode 100644 index 000000000..4878bd380 --- /dev/null +++ b/extensions/draw/public/theme.js @@ -0,0 +1,134 @@ +// Resolves the host app's theme tokens into hex colors, and measures text with the real font. +import { LIGHT_PALETTE, DARK_PALETTE, staticPaint, luminance, mixHex, textOn } from "/lib/model.mjs"; +import { DEFAULT_FONT } from "/lib/render.mjs"; + +export const HOST_TOKENS = [ + "--background-color-default", + "--text-color-default", + "--text-color-muted", + "--border-color-default", + "--color-focus-outline", + "--font-sans", + "--font-mono", +]; + +const pixel = document.createElement("canvas"); +pixel.width = pixel.height = 1; +const pctx = pixel.getContext("2d", { willReadFrequently: true }); +let probe = null; + +// Any CSS color (rgb, oklch, color-mix, names) -> "#rrggbb", or null when it is not a usable color. +export function cssToHex(value) { + if (!value) return null; + if (!probe) { + probe = document.createElement("span"); + probe.style.display = "none"; + document.body.append(probe); + } + probe.style.color = ""; + probe.style.color = value; + if (!probe.style.color) return null; + const computed = getComputedStyle(probe).color; + pctx.clearRect(0, 0, 1, 1); + pctx.fillStyle = "#000"; + pctx.fillStyle = computed; + pctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = pctx.getImageData(0, 0, 1, 1).data; + if (a < 8) return null; + return "#" + [r, g, b].map((v) => v.toString(16).padStart(2, "0")).join(""); +} + +export const THEME_MODES = ["app", "light", "dark"]; + +// mode "app" follows the host. "light" and "dark" use the host's colors only when its tone +// matches, and the built-in palette otherwise. The host's fonts are used either way. +export function readTheme(mode = "app") { + const styles = getComputedStyle(document.body); + const tok = (name) => styles.getPropertyValue(name).trim(); + const present = HOST_TOKENS.filter((name) => tok(name)); + const hostBg = cssToHex(tok("--background-color-default")); + // Before its colors load, the host already marks the tone on (data-theme-tone). + const tone = document.documentElement.dataset.themeTone; + const hostDark = hostBg + ? luminance(hostBg) < 0.4 + : tone === "dark" || tone === "light" ? tone === "dark" : matchMedia("(prefers-color-scheme: dark)").matches; + const dark = mode === "light" ? false : mode === "dark" ? true : hostDark; + const hostColor = (name) => (dark === hostDark ? cssToHex(tok(name)) : null); + const base = dark ? DARK_PALETTE : LIGHT_PALETTE; + const bg = hostColor("--background-color-default") || base.bg; + const ink = hostColor("--text-color-default") || base.ink; + const muted = hostColor("--text-color-muted") || mixHex(ink, bg, 0.62); + const border = hostColor("--border-color-default") || mixHex(ink, bg, dark ? 0.24 : 0.17); + const accent = hostColor("--color-focus-outline") || base.blue; + const font = tok("--font-sans") || DEFAULT_FONT; + const mono = tok("--font-mono") || "ui-monospace, 'Cascadia Code', Consolas, monospace"; + const palette = { ...base, bg, ink }; + return { + mode, dark, bg, ink, muted, border, accent, font, mono, palette, present, + danger: base.red, + paint: staticPaint(palette), + }; +} + +// Pushes theme colors into CSS variables on the app root (not , which the host may own). +export function applyThemeVars(root, t) { + const set = (k, v) => root.style.setProperty(k, v); + set("--d-bg", t.bg); + set("--d-ink", t.ink); + set("--d-muted", t.muted); + set("--d-border", t.border); + set("--d-accent", t.accent); + set("--d-on-accent", textOn(t.accent)); + set("--d-hover", mixHex(t.ink, t.bg, t.dark ? 0.1 : 0.06)); + set("--d-active", mixHex(t.ink, t.bg, t.dark ? 0.16 : 0.1)); + set("--d-accent-soft", mixHex(t.accent, t.bg, t.dark ? 0.24 : 0.12)); + set("--d-dot", mixHex(t.ink, t.bg, t.dark ? 0.22 : 0.2)); + set("--d-danger", t.danger); + set("--d-on-danger", textOn(t.danger)); + set("--d-font", t.font); + set("--d-mono", t.mono); + set("--d-shadow", t.dark ? "0 1px 2px rgba(0,0,0,0.4)" : "0 1px 2px rgba(31,35,40,0.06)"); + set("--d-pop-shadow", t.dark ? "0 8px 28px rgba(0,0,0,0.55)" : "0 8px 28px rgba(31,35,40,0.16)"); + root.style.colorScheme = t.dark ? "dark" : "light"; +} + +// Calls back (debounced) when the host changes theme attributes, stylesheets or the OS scheme. +export function watchTheme(callback) { + let timer = 0; + const schedule = () => { + clearTimeout(timer); + timer = setTimeout(callback, 16); + }; + const mo = new MutationObserver(schedule); + mo.observe(document.documentElement, { attributes: true }); + mo.observe(document.body, { attributes: true }); + mo.observe(document.head, { childList: true, subtree: true, characterData: true }); + matchMedia("(prefers-color-scheme: dark)").addEventListener("change", schedule); +} + +// Text measurement with the real UI font. Cached, because layout measures the same strings a lot. +const mctx = document.createElement("canvas").getContext("2d"); +const cache = new Map(); +let family = DEFAULT_FONT; + +export function setMeasureFont(font) { + if (font === family) return; + family = font; + cache.clear(); +} + +export function clearMeasureCache() { + cache.clear(); +} + +export function measure(str, fontSize, weight = 400) { + const key = `${weight}|${fontSize}|${str}`; + let w = cache.get(key); + if (w === undefined) { + mctx.font = `${weight} ${fontSize}px ${family}`; + w = mctx.measureText(str).width; + if (cache.size > 20000) cache.clear(); + cache.set(key, w); + } + return w; +} diff --git a/extensions/draw/public/ui.js b/extensions/draw/public/ui.js new file mode 100644 index 000000000..63b0b31ba --- /dev/null +++ b/extensions/draw/public/ui.js @@ -0,0 +1,419 @@ +// Tool rail, style bar, zoom controls, save status, toasts and popovers. +import { COLORS, SHAPE_TYPES, SIZE_KEYS, PEN_WIDTHS } from "/lib/model.mjs"; +import { icon } from "./icons.js"; +import { categoryOf, STYLE_KEYS } from "./editor.js"; +import { esc, capitalize } from "./util.js"; +import { makeAnnouncer, TYPE_NAMES } from "./announce.js"; + +const $ = (id) => document.getElementById(id); + +const TOOLS = [ + ["select", "V", "Select"], ["hand", "H", "Pan"], null, + ["rect", "R", "Rectangle"], ["ellipse", "O", "Ellipse"], ["diamond", "D", "Diamond (decision)"], ["cylinder", "C", "Database"], null, + ["arrow", "A", "Arrow"], ["text", "T", "Text"], ["pen", "P", "Pen"], +]; +// What each tool does, read out when it is picked from the keyboard. Shape tools share one. +const TOOL_TIPS = { + select: "Select tool. N selects the next element.", + hand: "Pan tool. Drag to pan, or use the arrow keys when nothing is selected.", + arrow: "Arrow tool. Select a shape and press Enter, then select the shape it goes to and press Enter.", + text: "Text tool. Press Enter to add text in the middle, or click the canvas.", + pen: "Pen tool. Draw with a mouse, pen or touch.", +}; +const SIZE_LABELS = { s: "Small", m: "Medium", l: "Large", xl: "Extra large" }; +const FILL_OPTIONS = [["none", "No fill"], ["soft", "Light fill"], ["solid", "Solid fill"]]; +const HEAD_OPTIONS = [["end", "Arrow at the end"], ["both", "Arrows at both ends"], ["none", "No arrowheads"]]; +const ROUTE_OPTIONS = [["straight", "Straight"], ["curve", "Curved"], ["elbow", "Elbow"]]; + +const penSizeKey = (w) => SIZE_KEYS.reduce((best, k) => (Math.abs(PEN_WIDTHS[k] - w) < Math.abs(PEN_WIDTHS[best] - w) ? k : best), "m"); + +export class UI { + constructor(ed) { + this.ed = ed; + this.sync = null; + this.doc = null; + this.drawings = []; + this.folder = ""; + this.popover = null; + this.statusTimer = 0; + this.connected = true; + this.sbHtml = ""; + this.sbRaf = 0; + this.announce = makeAnnouncer($("announce")); + this.buildTools(); + this.buildButtons(); + this.bindEditor(); + } + + // ---------- tool rail and buttons ---------- + + buildTools() { + const nav = $("tools"); + nav.innerHTML = TOOLS.map((t) => + t + ? `` + : '', + ).join(""); + nav.addEventListener("mousedown", (e) => e.preventDefault()); + nav.addEventListener("click", (e) => { + const b = e.target.closest("[data-tool]"); + if (!b) return; + this.ed.setTool(b.dataset.tool); + // Picked with the keyboard (a click without a pointer), so go on to the canvas, where Enter + // adds a shape. + if (e.detail === 0) { + this.ed.focusCanvas(); + this.announceTool(b.dataset.tool); + } + }); + this.syncTools(); + } + + announceTool(tool) { + const name = TOOLS.find((t) => t && t[0] === tool)?.[2] || "Shape"; + this.announce(TOOL_TIPS[tool] || `${name} tool. Press Enter to add one in the middle, or click the canvas.`); + } + + syncTools() { + for (const b of $("tools").querySelectorAll("[data-tool]")) b.setAttribute("aria-pressed", String(b.dataset.tool === this.ed.tool)); + } + + buildButtons() { + const ed = this.ed; + const icons = { undo: "undo", redo: "redo", "zoom-out": "minus", "zoom-in": "plus", "zoom-fit": "fit", "help-button": "help" }; + for (const [id, name] of Object.entries(icons)) $(id).innerHTML = icon(name); + // These act on the canvas, so they should not take keyboard focus away from it. + const run = (fn) => () => { + if (ed.editing) ed.labels.commit(); + fn(); + }; + const actions = { + undo: () => ed.undo(), + redo: () => ed.redo(), + "zoom-out": () => ed.zoomBy(0.8), + "zoom-in": () => ed.zoomBy(1.25), + "zoom-level": () => ed.zoomTo(1), + "zoom-fit": () => ed.fit({ maxZoom: 2 }), + }; + for (const [id, fn] of Object.entries(actions)) { + $(id).addEventListener("mousedown", (e) => e.preventDefault()); + $(id).addEventListener("click", run(fn)); + } + const sb = $("stylebar"); + sb.addEventListener("mousedown", (e) => e.preventDefault()); + sb.addEventListener("click", (e) => this.onStyleClick(e)); + } + + bindEditor() { + const ed = this.ed; + ed.on("tool", () => { + this.syncTools(); + this.scheduleStylebar(); + }); + ed.on("selection", () => this.scheduleStylebar()); + ed.on("style", () => this.scheduleStylebar()); + ed.on("change", () => { + this.scheduleStylebar(); + this.syncEmpty(); + }); + ed.on("editing", () => this.syncEmpty()); + ed.on("history", () => this.syncHistory()); + ed.on("view", () => this.syncZoom()); + ed.on("hint", (msg) => this.toast(msg)); + ed.on("announce", (msg) => this.announce(msg)); + } + + setup(state) { + this.folder = state.folder || ""; + this.setDoc(state.drawing); + this.setDrawings(state.drawings || []); + this.syncHistory(); + this.syncZoom(); + this.syncEmpty(); + this.renderStylebar(); + } + + setDoc(doc) { + this.doc = { id: doc.id, name: doc.name }; + $("doc-name").textContent = doc.name; + $("svg").setAttribute("aria-label", doc.name); + document.title = `${doc.name} - Draw`; + } + + setDrawings(list) { + this.drawings = list; + const cur = this.doc && list.find((d) => d.id === this.doc.id); + if (cur && cur.name !== this.doc.name) this.setDoc(cur); + } + + refresh() { + this.sbHtml = ""; + this.renderStylebar(); + } + + syncEmpty() { + $("empty").hidden = this.ed.elements.length > 0 || !!this.ed.editing; + } + + syncHistory() { + $("undo").disabled = !this.ed.history.length; + $("redo").disabled = !this.ed.future.length; + } + + syncZoom() { + $("zoom-level").textContent = `${Math.round(this.ed.view.zoom * 100)}%`; + } + + // ---------- style bar ---------- + + scheduleStylebar() { + if (this.sbRaf) return; + this.sbRaf = requestAnimationFrame(() => { + this.sbRaf = 0; + if (!this.ed.interaction) this.renderStylebar(); + }); + } + + renderStylebar() { + const ed = this.ed; + const bar = $("stylebar"); + const sel = ed.selected(); + let items; + if (sel.length) { + items = sel.map((el) => ({ cat: categoryOf(el), v: el })); + } else { + const cat = ed.toolCategory(); + items = cat ? [{ cat, v: ed.styles[cat] }] : []; + } + if (!items.length) { + // A hidden bar cannot keep focus, so the canvas gets it, as when Escape closes a menu. + if (bar.contains(document.activeElement)) ed.focusCanvas(); + bar.hidden = true; + return; + } + const cats = new Set(items.map((i) => i.cat)); + const common = (key) => { + const vals = items.filter((i) => STYLE_KEYS[i.cat].includes(key)).map((i) => i.v[key]); + return vals.length && vals.every((x) => x === vals[0]) ? vals[0] : undefined; + }; + const sizes = items.map((i) => (i.cat === "pen" ? penSizeKey(i.v.width) : i.v.size)); + const size = sizes.every((x) => x === sizes[0]) ? sizes[0] : undefined; + const btn = (k, v, pressed, title, inner, cls = "") => + ``; + const act = (a, name, title) => ``; + const group = (label, html) => `
${html}
`; + const options = (k, list, cur, iconPrefix) => list.map(([v, title]) => btn(k, v, v === cur, title, icon(iconPrefix + v))).join(""); + + const color = common("color"); + const parts = [ + group("Color", COLORS.map((c) => + btn("color", c, c === color, capitalize(c), ``, "swatch"), + ).join("")), + ]; + if (cats.has("shape") && sel.length) { + const type = common("type"); + parts.push(group("Shape", SHAPE_TYPES.map((t) => btn("type", t, t === type, TYPE_NAMES[t], icon(t))).join(""))); + } + if (cats.has("shape")) parts.push(group("Fill", options("fill", FILL_OPTIONS, common("fill"), "fill-"))); + if (cats.has("shape") || cats.has("arrow")) { + const dash = common("dash"); + parts.push(group("Line", btn("dash", "false", dash === false, "Solid line", icon("line-solid")) + btn("dash", "true", dash === true, "Dashed line", icon("line-dashed")))); + } + if (cats.has("arrow")) { + parts.push(group("Arrowheads", options("head", HEAD_OPTIONS, common("head"), "head-"))); + parts.push(group("Route", options("route", ROUTE_OPTIONS, common("route"), "route-"))); + } + const sizeTitle = cats.size === 1 && cats.has("pen") ? "Stroke" : "Text size"; + parts.push(group(sizeTitle, SIZE_KEYS.map((k) => btn("size", k, k === size, `${SIZE_LABELS[k]} ${sizeTitle.toLowerCase()}`, k.toUpperCase(), "size-btn")).join(""))); + if (sel.length) { + // Arrows are always drawn on top, so only other elements can move between layers. + const layered = sel.some((el) => el.type !== "arrow"); + parts.push(group("Arrange", [ + ...(layered ? [act("front", "front", "Bring to front (Ctrl+])"), act("back", "back", "Send to back (Ctrl+[)")] : []), + act("duplicate", "duplicate", "Duplicate (Ctrl+D)"), + act("delete", "trash", "Delete (Del)"), + ].join(""))); + } + const html = `
${parts.join("")}
`; + if (html !== this.sbHtml) { + // Rebuilding the bar removes the focused button, so the same button in the new bar gets + // focus, and keyboard users stay where they were. + const focused = bar.contains(document.activeElement) ? { ...document.activeElement.dataset } : null; + bar.innerHTML = html; + this.sbHtml = html; + if (focused) { + const same = [...bar.querySelectorAll("button")].find((b) => + b.dataset.k === focused.k && b.dataset.v === focused.v && b.dataset.act === focused.act); + if (same) same.focus({ preventScroll: true }); + else ed.focusCanvas(); + } + } + bar.hidden = false; + } + + onStyleClick(e) { + const b = e.target.closest("button"); + if (!b) return; + const ed = this.ed; + if (ed.editing) ed.labels.commit(); + const a = b.dataset.act; + if (a === "front") return ed.reorder(true); + if (a === "back") return ed.reorder(false); + if (a === "duplicate") return ed.duplicate(); + if (a === "delete") return ed.deleteSelection(); + const k = b.dataset.k; + if (!k) return; + const v = b.dataset.v; + if (k === "dash") ed.applyStyle({ dash: v === "true" }); + else if (k === "size") ed.applyStyle({ size: v, width: PEN_WIDTHS[v] }); + else ed.applyStyle({ [k]: v }); + } + + // ---------- status and toasts ---------- + + // Quiet by default: "Saving" only shows when a save is slow, and errors stay until it recovers. + setStatus(kind, msg) { + clearTimeout(this.statusTimer); + const el = $("save-status"); + if (kind === "saving") { + if (!el.classList.contains("is-error")) this.statusTimer = setTimeout(() => this.showStatus("Saving"), 600); + return; + } + if (kind === "saved") { + if (el.textContent) { + this.showStatus("Saved"); + this.statusTimer = setTimeout(() => this.showStatus(""), 1500); + } + return; + } + this.showStatus(msg || "Not saved", true); + } + + showStatus(text, error = false) { + const el = $("save-status"); + el.textContent = text; + // A long message gets cut off, so hovering shows all of it. + el.title = text; + el.classList.toggle("is-error", error); + } + + setConnection(ok) { + if (ok === this.connected) return; + this.connected = ok; + if (!ok) this.showStatus("Reconnecting", true); + else if ($("save-status").textContent === "Reconnecting") this.showStatus(""); + } + + toast(msg, { error = false, action = null, timeout = 3200 } = {}) { + const box = $("toasts"); + const el = document.createElement("div"); + el.className = `toast${error ? " is-error" : ""}`; + el.setAttribute("role", error ? "alert" : "status"); + const text = document.createElement("span"); + text.textContent = msg; + el.append(text); + const remove = () => el.remove(); + if (action) { + const b = document.createElement("button"); + b.type = "button"; + b.textContent = action.label; + b.addEventListener("click", () => { + remove(); + action.run(); + }); + el.append(b); + } + box.append(el); + while (box.children.length > 3) box.firstChild.remove(); + setTimeout(remove, action ? Math.max(timeout, 6000) : error ? Math.max(timeout, 5000) : timeout); + } + + // Draw stayed on this drawing because the edits on screen are not saved, and switching would + // lose them. The status line says why. `discard` goes ahead anyway. + warnUnsaved(discard) { + this.toast("Your latest changes are not saved yet, so Draw stayed on this drawing.", { + error: true, + timeout: 12000, + action: { label: "Discard changes", run: discard }, + }); + } + + fatal(message) { + $("stage").innerHTML = + `

This drawing could not load

${esc(message)}

` + + '

'; + $("reload").addEventListener("click", () => location.reload()); + } + + // ---------- popovers ---------- + + openPopover(anchor, build, { className = "", placement = "below", align = "start", role = "dialog", label = "" } = {}) { + this.closePopover(); + const pop = document.createElement("div"); + pop.className = `popover ${className}`; + pop.setAttribute("role", role); + // Screen readers announce a dialog or menu by its name, so each one gets one. + pop.setAttribute("aria-label", label || anchor.getAttribute("aria-label") || anchor.title || anchor.textContent.trim()); + $("popovers").append(pop); + const close = () => this.closePopover(); + build(pop, close); + const r = anchor.getBoundingClientRect(); + const w = pop.offsetWidth; + const h = pop.offsetHeight; + const left = Math.max(8, Math.min(align === "end" ? r.right - w : r.left, innerWidth - w - 8)); + let top = r.bottom + 6; + if (placement === "above" || top + h > innerHeight - 8) top = Math.max(8, r.top - h - 6); + pop.style.left = `${left}px`; + pop.style.top = `${top}px`; + anchor.setAttribute("aria-expanded", "true"); + const onDown = (e) => { + if (!pop.contains(e.target) && !anchor.contains(e.target)) close(); + }; + const onKey = (e) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + close(); + anchor.focus({ preventScroll: true }); + return; + } + if (pop.getAttribute("role") === "menu" && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + const list = [...pop.querySelectorAll(".menu-item")]; + const i = list.indexOf(document.activeElement); + const next = list[(i + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]; + next?.focus(); + e.preventDefault(); + } + }; + document.addEventListener("pointerdown", onDown, true); + pop.addEventListener("keydown", onKey); + this.popover = { + pop, + anchor, + cleanup: () => { + document.removeEventListener("pointerdown", onDown, true); + anchor.setAttribute("aria-expanded", "false"); + pop.remove(); + }, + }; + return pop; + } + + togglePopover(anchor, build, opts) { + if (this.popover && this.popover.anchor === anchor) { + this.closePopover(); + return null; + } + return this.openPopover(anchor, build, opts); + } + + closePopover() { + if (!this.popover) return false; + const p = this.popover; + this.popover = null; + const hadFocus = p.pop.contains(document.activeElement); + p.cleanup(); + if (hadFocus) this.ed.focusCanvas(); + return true; + } +} diff --git a/extensions/draw/public/util.js b/extensions/draw/public/util.js new file mode 100644 index 000000000..082fdb653 --- /dev/null +++ b/extensions/draw/public/util.js @@ -0,0 +1,13 @@ +export const esc = (s) => + String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); + +export const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); + +export async function blobToBase64(blob) { + const bytes = new Uint8Array(await blob.arrayBuffer()); + let s = ""; + for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + return btoa(s); +} + +export const fileName = (p) => String(p).split(/[\\/]/).pop(); diff --git a/extensions/draw/server.mjs b/extensions/draw/server.mjs new file mode 100644 index 000000000..38e6d695f --- /dev/null +++ b/extensions/draw/server.mjs @@ -0,0 +1,514 @@ +// Loopback HTTP server for the Draw canvas iframe: static files, live updates (SSE) and a small JSON API. +import http from "node:http"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; +import { outlinePage } from "./lib/layout.mjs"; +import { MAX_ELEMENTS, sameJson } from "./lib/model.mjs"; +import { Settings, THEMES } from "./settings.mjs"; + +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", +}; +const MAX_BODY = 40 * 1024 * 1024; +// About how much outline goes into an Ask prompt. Copilot can read the rest with get_drawing. +const ASK_OUTLINE_BUDGET = 16000; +// How many pages the server remembers the last change number of (see lastSeq). +const SEQ_MEMORY = 500; +// How long open_drawing waits for the panel to say it shows the drawing (see openDrawing). +const SWITCH_TIMEOUT_MS = 10000; +const CSP = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'none'", +].join("; "); + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +function staticPath(pathname) { + if (pathname === "/" || pathname === "/index.html") return path.join(ROOT, "public", "index.html"); + let m = /^\/lib\/([a-z0-9-]+\.mjs)$/.exec(pathname); + if (m) return path.join(ROOT, "lib", m[1]); + m = /^\/([a-z0-9-]+\.(?:js|css|svg))$/.exec(pathname); + if (m) return path.join(ROOT, "public", m[1]); + return null; +} + +function sendJson(res, status, body) { + const data = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "Content-Length": Buffer.byteLength(data), + }); + res.end(data); +} + +async function readJson(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > MAX_BODY) throw new HttpError(413, "Request is too large."); + chunks.push(chunk); + } + try { + const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); + return value; + } catch { + throw new HttpError(400, "Request body must be a JSON object."); + } +} + +function sameToken(a, b) { + const x = Buffer.from(String(a || "")); + const y = Buffer.from(b); + return x.length === y.length && timingSafeEqual(x, y); +} + +function isInside(dir, file) { + const rel = path.relative(dir, file); + return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel); +} + +// Whether saving a page's ops stored something other than what the page has, because the model +// tidied it up: a size past the limit, say, or an arrow whose shape is gone. +function tidiedOnSave(before, ops, after) { + const saved = new Map(after.map((e) => [e.id, e])); + const ids = new Set(before.map((e) => e.id)); + for (const id of Array.isArray(ops.deletes) ? ops.deletes : []) ids.delete(id); + for (const el of Array.isArray(ops.upserts) ? ops.upserts : []) { + if (!sameJson(el, saved.get(el?.id))) return true; + ids.add(el.id); + } + return ids.size !== after.length; +} + +function revealInFolder(file) { + const opts = { detached: true, stdio: "ignore" }; + let child; + if (process.platform === "win32") { + child = spawn("explorer.exe", [`/select,"${file}"`], { ...opts, windowsVerbatimArguments: true }); + } else if (process.platform === "darwin") { + child = spawn("open", ["-R", file], opts); + } else { + child = spawn("xdg-open", [path.dirname(file)], opts); + } + child.on("error", () => {}); + child.unref(); +} + +export function publicDoc(doc, saveError = null) { + return { id: doc.id, name: doc.name, rev: doc.rev, updatedAt: doc.updatedAt, elements: doc.elements, saveError }; +} + +export function createDrawServer({ store, settings = new Settings(), getSession, log = () => {} }) { + const token = randomBytes(18).toString("base64url"); + const clients = new Set(); + const selections = new Map(); + const pendingExports = new Map(); + // Drawings an agent opened, until each page of the panel says it shows them (see openDrawing). + const pendingSwitches = new Map(); + // The number of the newest change each page has had applied. A page sends one change at a + // time, except when it closes: its last change can then overtake one still on the way, and + // that older one must not undo it. + const lastSeq = new Map(); + let port = 0; + + // An event is serialized once, however many clients it goes to. + const write = (client, frame) => { + try { + client.res.write(frame); + } catch { + dropClient(client); + } + }; + const sendAll = (list, event, data) => { + if (!list.length) return; + const frame = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const c of list) write(c, frame); + }; + const send = (client, event, data) => sendAll([client], event, data); + const clientsFor = (instanceId) => [...clients].filter((c) => c.instanceId === instanceId); + const showing = (drawingId) => [...clients].filter((c) => store.state.instances[c.instanceId] === drawingId); + const pub = (doc) => publicDoc(doc, store.saveError(doc.id)); + + // Makes sure an instance points at a real drawing, creating "Drawing 1" when there are none. + function ensureDrawing(instanceId) { + let doc = store.drawingForInstance(instanceId); + if (!doc) { + const recent = store.list()[0]; + doc = store.lastOpened || (recent && store.get(recent.id)) || store.create(); + store.bindInstance(instanceId, doc.id); + } + return doc; + } + + function showDrawing(instanceId, doc, switchId) { + // The selection is of the drawing on screen, so it only goes when the panel shows another one. + if (store.drawingForInstance(instanceId)?.id !== doc.id) selections.delete(instanceId); + store.bindInstance(instanceId, doc.id); + sendAll(clientsFor(instanceId), "switch", { drawing: pub(doc), switchId }); + } + + // Shows another drawing in a panel for the agent, and waits for the panel to take it: a page + // with edits it cannot save first stays on its drawing and moves the panel back (see switchTo + // in public/sync.js). Resolves "shown", "refused" (the panel is back on its drawing by then) or + // "unconfirmed" when a page does not answer in time. With no page open there is nothing to wait + // for, since a page shows the panel's drawing when it loads. + function openDrawing(instanceId, doc, timeoutMs = SWITCH_TIMEOUT_MS) { + const waiting = new Set(clientsFor(instanceId).map((c) => c.clientId)); + if (!waiting.size) { + showDrawing(instanceId, doc); + return Promise.resolve("shown"); + } + const switchId = randomBytes(8).toString("hex"); + return new Promise((resolve) => { + const finish = (result) => { + pendingSwitches.delete(switchId); + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => finish("unconfirmed"), timeoutMs); + pendingSwitches.set(switchId, { instanceId, waiting, finish }); + showDrawing(instanceId, doc, switchId); + }); + } + + // A page of the panel took the drawing, or is gone and cannot answer. + function switchAnswered(switchId, instanceId, clientId) { + const pending = pendingSwitches.get(switchId); + if (!pending || pending.instanceId !== instanceId) return; + pending.waiting.delete(clientId); + if (!pending.waiting.size) pending.finish("shown"); + } + + function dropClient(client) { + clients.delete(client); + if (clientsFor(client.instanceId).some((c) => c.clientId === client.clientId)) return; + for (const switchId of pendingSwitches.keys()) switchAnswered(switchId, client.instanceId, client.clientId); + } + + // Settings apply to every panel, so every client hears about a change. + async function updateSettings(patch, origin = "agent") { + const values = await settings.update(patch); + sendAll([...clients], "settings", { settings: values, origin }); + return values; + } + + // A change goes out as the ops that made it (see diffOps), for the drawing as it was one + // change earlier, rather than as the whole drawing, which can run to many megabytes. A page + // that finds it missed a change fetches the whole drawing again. + store.on("change", (doc, origin, ops) => { + const others = showing(doc.id).filter((c) => c.clientId !== origin); + sendAll(others, "ops", { drawingId: doc.id, rev: doc.rev, baseRev: doc.rev - 1, ops, origin }); + }); + store.on("save", (id, error) => { + sendAll(showing(id), "save", { drawingId: id, error }); + }); + store.on("list", () => { + sendAll([...clients], "list", { drawings: store.list() }); + }); + + const heartbeat = setInterval(() => { + for (const c of clients) write(c, ": ping\n\n"); + }, 25000); + heartbeat.unref(); + + async function api(req, res, url, instanceId) { + const route = url.pathname; + if (req.method === "GET" && route === "/api/state") { + const doc = ensureDrawing(instanceId); + return sendJson(res, 200, { + instanceId, + drawing: pub(doc), + drawings: store.list(), + folder: store.dir, + settings: await settings.read(), + }); + } + if (req.method === "GET" && route === "/api/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-store", + Connection: "keep-alive", + }); + res.write("retry: 1500\n\n"); + const client = { res, instanceId, clientId: url.searchParams.get("c") || "" }; + clients.add(client); + req.on("close", () => dropClient(client)); + return; + } + if (req.method !== "POST") throw new HttpError(404, "Not found."); + const body = await readJson(req); + const clientId = typeof body.clientId === "string" ? body.clientId.slice(0, 64) : "user"; + + if (route === "/api/ops") { + const current = store.get(body.drawingId); + if (!current) throw new HttpError(404, "That drawing no longer exists."); + const seq = Number.isSafeInteger(body.seq) ? body.seq : null; + if (seq !== null && lastSeq.has(clientId) && seq <= lastSeq.get(clientId)) { + return sendJson(res, 200, { rev: current.rev, drawing: pub(current), ignored: true }); + } + const ops = body.ops && typeof body.ops === "object" ? body.ops : {}; + const before = current.elements; + const doc = store.applyOps(body.drawingId, ops, clientId); + // Only changes that were applied count, so a refused one cannot hide an older one. + if (seq !== null) { + lastSeq.delete(clientId); + lastSeq.set(clientId, seq); + if (lastSeq.size > SEQ_MEMORY) lastSeq.delete(lastSeq.keys().next().value); + } + // If someone else changed the drawing since the client's last known rev, send the full doc back. + const stale = Number.isInteger(body.baseRev) && doc.rev !== body.baseRev + 1; + // Also when the drawing was saved tidier than the page sent it, so the page shows what was saved. + const cleaned = !stale && tidiedOnSave(before, ops, doc.elements); + if (cleaned) return sendJson(res, 200, { rev: doc.rev, drawing: pub(doc), cleaned: true }); + return sendJson(res, 200, stale ? { rev: doc.rev, drawing: pub(doc) } : { rev: doc.rev }); + } + if (route === "/api/selection") { + const ids = Array.isArray(body.ids) ? body.ids.filter((id) => typeof id === "string").slice(0, MAX_ELEMENTS) : []; + selections.set(instanceId, { drawingId: body.drawingId, ids }); + return sendJson(res, 200, { ok: true }); + } + if (route === "/api/switched") { + switchAnswered(body.switchId, instanceId, clientId); + return sendJson(res, 200, { ok: true }); + } + if (route === "/api/drawings") { + return sendJson(res, 200, await drawingsAction(instanceId, body)); + } + if (route === "/api/export") { + return sendJson(res, 200, await exportUpload(body)); + } + if (route === "/api/ask") { + return sendJson(res, 200, await ask(instanceId, body)); + } + if (route === "/api/reveal") { + const file = path.resolve(String(body.path || "")); + if (!isInside(store.dir, file)) throw new HttpError(400, "Can only reveal files saved by the Draw canvas."); + revealInFolder(file); + return sendJson(res, 200, { ok: true }); + } + if (route === "/api/settings") { + if (!THEMES.includes(body.theme)) throw new HttpError(400, `Theme must be one of: ${THEMES.join(", ")}.`); + return sendJson(res, 200, { settings: await updateSettings({ theme: body.theme }, clientId) }); + } + throw new HttpError(404, "Not found."); + } + + async function drawingsAction(instanceId, body) { + const action = body.action; + let doc; + if (action === "new") { + doc = store.create(body.name); + } else if (action === "open") { + doc = store.get(body.id); + if (!doc) throw new HttpError(404, "That drawing no longer exists."); + } else if (action === "rename") { + try { + doc = store.rename(body.id, body.name); + } catch (err) { + throw new HttpError(400, err.message); + } + return { drawing: pub(doc), drawings: store.list() }; + } else if (action === "duplicate") { + const source = store.get(body.id); + if (!source) throw new HttpError(404, "That drawing no longer exists."); + doc = store.create(`${source.name} copy`); + store.replace(doc.id, source.elements, clientIdOf(body)); + } else if (action === "delete") { + if (!store.get(body.id)) throw new HttpError(404, "That drawing no longer exists."); + await store.remove(body.id); + const recent = store.list()[0]; + doc = (recent && store.get(recent.id)) || store.create(); + for (const c of clients) { + if (c.instanceId !== instanceId && !store.drawingForInstance(c.instanceId)) showDrawing(c.instanceId, doc); + } + } else { + throw new HttpError(400, "Unknown drawings action."); + } + store.bindInstance(instanceId, doc.id); + selections.delete(instanceId); + // A page that could not take a drawing the agent opened moves the panel back with this. + const refused = action === "open" && pendingSwitches.get(body.switchId); + if (refused && refused.instanceId === instanceId) refused.finish("refused"); + return { drawing: pub(doc), drawings: store.list() }; + } + + const clientIdOf = (body) => (typeof body.clientId === "string" ? body.clientId.slice(0, 64) : "user"); + + async function exportUpload(body) { + const format = body.format === "svg" ? "svg" : "png"; + if (body.requestId) { + const pending = pendingExports.get(body.requestId); + if (!pending) return { ok: false }; + pendingExports.delete(body.requestId); + clearTimeout(pending.timer); + // The panel says which drawing it drew, and a picture of another one must not be + // saved under this one's name. + if (body.drawingId !== pending.drawingId) { + pending.reject(new Error("The canvas switched to another drawing before it drew this one, so nothing was saved. Call export_image again to save the drawing it shows now.")); + } else if (body.error) pending.reject(new Error(String(body.error))); + else pending.resolve({ format, data: String(body.data || "") }); + return { ok: true }; + } + const doc = store.get(body.drawingId); + if (!doc) throw new HttpError(404, "That drawing no longer exists."); + const data = format === "png" ? Buffer.from(String(body.data || ""), "base64") : String(body.data || ""); + if (!data.length) throw new HttpError(400, "The export was empty."); + const file = await store.writeExport(doc.id, format, data); + return { path: file }; + } + + async function ask(instanceId, body) { + const text = typeof body.text === "string" ? body.text.trim().slice(0, 8000) : ""; + if (!text) throw new HttpError(400, "Type a question first."); + const doc = store.get(body.drawingId); + if (!doc) throw new HttpError(404, "That drawing no longer exists."); + // The context tells Copilot to use this panel, so it must still show the drawing asked about. + if (store.drawingForInstance(instanceId)?.id !== doc.id) { + throw new HttpError(409, "Another drawing opened, so your question was not sent. It is still in Ask, so you can send it about this one."); + } + const session = getSession(); + if (!session) throw new HttpError(503, "Copilot is not connected yet. Try again in a moment."); + const png = typeof body.png === "string" ? body.png : ""; + const part = outlinePage(doc, { budget: ASK_OUTLINE_BUDGET }); + const context = [ + `[Context from the Draw canvas: drawing "${doc.name}" (id ${doc.id}), canvas instance "${instanceId}".` + + (png ? " The attached image shows the drawing exactly as the user sees it." : ""), + part.text, + part.next === null ? null : `The outline stops after ${part.shown.length} of ${part.total} elements. Call get_drawing with start ${part.next} to read the rest.`, + `To change this drawing, call the Draw canvas actions with instanceId "${instanceId}" and drawingId "${doc.id}" (get_drawing, add_elements, update_elements, delete_elements, set_diagram, layout). With drawingId, an action changes nothing if the user has opened another drawing since. Element ids are listed above.]`, + ].filter(Boolean).join("\n"); + const attachments = png ? [{ type: "blob", data: png, mimeType: "image/png", displayName: `${doc.name}.png` }] : []; + try { + await session.send({ prompt: `${text}\n\n${context}`, displayPrompt: text, attachments }); + } catch (err) { + throw new HttpError(502, `Could not send to Copilot: ${err.message}`); + } + return { ok: true }; + } + + async function handle(req, res) { + const host = req.headers.host || ""; + if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) { + res.writeHead(403).end("Forbidden"); + return; + } + const url = new URL(req.url, `http://${host}`); + if (url.pathname.startsWith("/api/")) { + if (!sameToken(url.searchParams.get("t"), token)) throw new HttpError(401, "Missing or wrong token."); + const instanceId = url.searchParams.get("i") || ""; + if (!instanceId) throw new HttpError(400, "Missing canvas instance id."); + return api(req, res, url, instanceId); + } + if (req.method !== "GET" && req.method !== "HEAD") throw new HttpError(405, "Method not allowed."); + const file = staticPath(url.pathname); + if (!file) throw new HttpError(404, "Not found."); + let data; + try { + data = await fs.readFile(file); + } catch { + throw new HttpError(404, "Not found."); + } + // The page needs the theme before its first paint, so it is written into the HTML. + if (file.endsWith("index.html")) { + const { theme } = await settings.read(); + data = Buffer.from(String(data).replace('data-theme-mode="app"', `data-theme-mode="${theme}"`)); + } + const headers = { + "Content-Type": MIME[path.extname(file)] || "application/octet-stream", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Length": data.length, + }; + if (file.endsWith(".html")) headers["Content-Security-Policy"] = CSP; + res.writeHead(200, headers); + res.end(req.method === "HEAD" ? undefined : data); + } + + const server = http.createServer((req, res) => { + handle(req, res).catch((err) => { + // HttpError and StoreError carry their own status. + const status = Number.isInteger(err.status) ? err.status : 500; + if (status === 500) log(`Draw server error: ${err.stack || err.message}`); + if (!res.headersSent) sendJson(res, status, { error: err.message }); + else res.end(); + }); + }); + + return { + token, + get port() { + return port; + }, + start() { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + port = server.address().port; + server.off("error", reject); + resolve(port); + }); + }); + }, + urlFor(instanceId) { + return `http://127.0.0.1:${port}/?i=${encodeURIComponent(instanceId)}&t=${token}`; + }, + ensureDrawing, + showDrawing, + openDrawing, + readSettings: () => settings.read(), + updateSettings, + hasClient: (instanceId) => clientsFor(instanceId).length > 0, + selection(instanceId, drawingId) { + const sel = selections.get(instanceId); + return sel && sel.drawingId === drawingId ? sel.ids : []; + }, + select(instanceId, drawingId, ids) { + selections.set(instanceId, { drawingId, ids }); + sendAll(clientsFor(instanceId), "select", { drawingId, ids }); + }, + // Asks the open iframe to render a drawing (it has the real fonts and theme). The request + // names the drawing, so a panel that has moved on to another one cannot answer for it. + requestExport(instanceId, drawingId, format, timeoutMs = 15000) { + const target = clientsFor(instanceId).pop(); + if (!target) return null; + const requestId = randomBytes(8).toString("hex"); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingExports.delete(requestId); + reject(new Error("The Draw canvas did not answer the export request in time.")); + }, timeoutMs); + pendingExports.set(requestId, { resolve, reject, timer, drawingId }); + send(target, "export", { requestId, drawingId, format }); + }); + }, + close() { + clearInterval(heartbeat); + for (const c of clients) c.res.end(); + // Saves and list updates can still fire on timers, and they must not write to ended streams. + clients.clear(); + server.close(); + }, + }; +} diff --git a/extensions/draw/server.test.mjs b/extensions/draw/server.test.mjs new file mode 100644 index 000000000..964ffbebc --- /dev/null +++ b/extensions/draw/server.test.mjs @@ -0,0 +1,556 @@ +// Tests for the loopback server: who may talk to it, which files it serves, and its JSON API. +// Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import http from "node:http"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { mergeOps, normalizeElement } from "./lib/model.mjs"; +import { createDrawServer } from "./server.mjs"; +import { Settings } from "./settings.mjs"; +import { DrawingStore } from "./store.mjs"; + +const PANEL = "panel"; +// Elements as the canvas sends them: complete, in the form the drawing stores. +const rect = (id, x = 0) => normalizeElement({ id, type: "rect", x, y: 0, w: 100, h: 60 }); + +// A server on a temp folder. When the test ends, it stops and the folder is removed. +async function setup(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), "draw-server-test-")); + const store = new DrawingStore(path.join(dir, "drawings")); + await store.ready; + const settings = new Settings(path.join(dir, "settings", "settings.json")); + const ctx = { dir, store, settings, session: null }; + ctx.server = createDrawServer({ store, settings, getSession: () => ctx.session }); + await ctx.server.start(); + ctx.doc = ctx.server.ensureDrawing(PANEL); + t.after(async () => { + ctx.server.close(); + await store.flush().catch(() => {}); + await rm(dir, { recursive: true, force: true, maxRetries: 3 }); + }); + return ctx; +} + +// One request on its own connection, so no socket outlives the test. +function request(port, { method = "GET", url = "/", headers = {}, body } = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port, method, path: url, headers, agent: false }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + let json = null; + try { + json = JSON.parse(text); + } catch { + // Not JSON, for example the page itself. + } + resolve({ status: res.statusCode, headers: res.headers, text, json }); + }); + }); + req.setTimeout(5000, () => req.destroy(new Error(`${method} ${url} timed out`))); + req.on("error", reject); + req.end(body); + }); +} + +const apiPath = (server, route, { instance = PANEL, token = server.token } = {}) => + `${route}?${new URLSearchParams({ i: instance, t: token })}`; +const get = (server, url, headers) => request(server.port, { url, headers }); +// The canvas sends its JSON as text/plain, so the tests do too. +const post = (server, route, body) => + request(server.port, { + method: "POST", + url: apiPath(server, route), + headers: { "Content-Type": "text/plain;charset=utf-8" }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + +// Opens a panel's live event stream. next(type) resolves with the next event of that type, and +// close() ends the stream the way a page that goes away does. +async function listen(t, server, clientId = "listener") { + const queue = []; + let wake = null; + const req = http.get({ host: "127.0.0.1", port: server.port, path: `${apiPath(server, "/api/events")}&c=${clientId}`, agent: false }); + t.after(() => req.destroy()); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assert.equal(res.statusCode, 200); + assert.match(res.headers["content-type"], /^text\/event-stream/); + let buffer = ""; + res.setEncoding("utf8"); + res.on("error", () => {}); + res.on("data", (chunk) => { + buffer += chunk; + let end; + while ((end = buffer.indexOf("\n\n")) >= 0) { + const block = buffer.slice(0, end); + buffer = buffer.slice(end + 2); + const type = /^event: (.*)$/m.exec(block)?.[1]; + if (type) queue.push({ type, data: JSON.parse(/^data: (.*)$/m.exec(block)[1]) }); + } + wake?.(); + }); + return { + close: () => req.destroy(), + async next(type, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const i = queue.findIndex((e) => e.type === type); + if (i >= 0) return queue.splice(i, 1)[0].data; + const left = deadline - Date.now(); + if (left <= 0) throw new Error(`No "${type}" event arrived.`); + await new Promise((resolve) => { + const timer = setTimeout(resolve, left); + wake = () => { + clearTimeout(timer); + resolve(); + }; + }); + } + }, + }; +} + +test("requests for any other host are refused", async (t) => { + const { server } = await setup(t); + const hosts = ["evil.example", `evil.example:${server.port}`, `127.0.0.1:${server.port + 1}`, `localhost.evil.example:${server.port}`]; + for (const host of hosts) { + assert.equal((await get(server, "/", { Host: host })).status, 403, host); + assert.equal((await get(server, apiPath(server, "/api/state"), { Host: host })).status, 403, host); + } + assert.equal((await get(server, "/", { Host: `localhost:${server.port}` })).status, 200); + assert.equal((await get(server, "/")).status, 200); +}); + +test("the API needs the server's token and a panel id", async (t) => { + const { server } = await setup(t); + const noToken = await get(server, `/api/state?i=${PANEL}`); + assert.equal(noToken.status, 401); + assert.equal(noToken.json.error, "Missing or wrong token."); + assert.equal((await get(server, apiPath(server, "/api/state", { token: "x".repeat(server.token.length) }))).status, 401); + assert.equal((await get(server, apiPath(server, "/api/state", { token: server.token.slice(1) }))).status, 401); + assert.equal((await get(server, `/api/events?i=${PANEL}`)).status, 401); + assert.equal((await request(server.port, { method: "POST", url: `/api/ops?i=${PANEL}`, body: "{}" })).status, 401); + assert.equal((await get(server, `/api/state?t=${server.token}`)).status, 400); + assert.equal((await get(server, apiPath(server, "/api/state"))).status, 200); +}); + +test("the page is sent with a content security policy and the saved theme", async (t) => { + const { server, settings } = await setup(t); + await settings.update({ theme: "dark" }); + const page = await get(server, `/?i=${PANEL}&t=${server.token}`); + assert.equal(page.status, 200); + assert.match(page.headers["content-type"], /^text\/html/); + assert.equal(page.headers["x-content-type-options"], "nosniff"); + const csp = page.headers["content-security-policy"]; + for (const rule of ["default-src 'self'", "script-src 'self'", "connect-src 'self'", "base-uri 'none'", "form-action 'none'"]) { + assert.ok(csp.includes(rule), rule); + } + assert.ok(page.text.includes('data-theme-mode="dark"')); +}); + +test("only the canvas's own files are served", async (t) => { + const { server } = await setup(t); + for (const url of ["/app.js", "/styles.css", "/lib/model.mjs"]) { + assert.equal((await get(server, url)).status, 200, url); + } + const hidden = ["/server.mjs", "/store.mjs", "/lib/../server.mjs", "/%2e%2e/server.mjs", "/lib/..%2fserver.mjs", "/public/app.js", "/lib/model.test.mjs", "/.state.json"]; + for (const url of hidden) { + assert.equal((await get(server, url)).status, 404, url); + } + const head = await request(server.port, { method: "HEAD", url: "/" }); + assert.equal(head.status, 200); + assert.equal(head.text, ""); + assert.equal((await request(server.port, { method: "POST", url: "/" })).status, 405); +}); + +test("state returns the panel's drawing, the drawing list and the settings", async (t) => { + const { server, store, doc } = await setup(t); + const { status, json } = await get(server, apiPath(server, "/api/state")); + assert.equal(status, 200); + assert.equal(json.instanceId, PANEL); + assert.equal(json.drawing.id, doc.id); + assert.equal(json.drawing.saveError, null); + assert.deepEqual(json.drawings.map((d) => d.id), [doc.id]); + assert.equal(json.folder, store.dir); + assert.deepEqual(json.settings, { theme: "app" }); +}); + +test("ops change the drawing, and a client that is behind gets the whole drawing back", async (t) => { + const { server, store, doc } = await setup(t); + const first = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [rect("a")] } }); + assert.equal(first.status, 200); + assert.deepEqual(first.json, { rev: 1 }); + const behind = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [rect("b", 200)] } }); + assert.equal(behind.json.rev, 2); + assert.deepEqual(behind.json.drawing.elements.map((e) => e.id), ["a", "b"]); + assert.equal(store.get(doc.id).elements.length, 2); + assert.equal((await post(server, "/api/ops", { drawingId: "missing", ops: {} })).status, 404); +}); + +test("ops that would go past 5,000 elements are refused and change nothing", async (t) => { + const { server, store, doc } = await setup(t); + const upserts = Array.from({ length: 5001 }, (_, i) => rect(`r${i}`, i * 10)); + const res = await post(server, "/api/ops", { drawingId: doc.id, ops: { upserts } }); + assert.equal(res.status, 400); + assert.match(res.json.error, /up to 5,000 elements/); + assert.equal(store.get(doc.id).rev, 0); + assert.equal(store.get(doc.id).elements.length, 0); +}); + +test("a panel's selection can hold as many ids as a drawing has elements", async (t) => { + const { server, doc } = await setup(t); + const ids = Array.from({ length: 5010 }, (_, i) => `r${i}`); + const res = await post(server, "/api/selection", { drawingId: doc.id, ids: [42, ...ids] }); + assert.equal(res.status, 200); + assert.deepEqual(server.selection(PANEL, doc.id), ids.slice(0, 5000)); + assert.deepEqual(server.selection(PANEL, "another-drawing"), []); +}); + +test("showing the drawing already on screen keeps its selection, and showing another drops it", async (t) => { + const { server, store, doc } = await setup(t); + await post(server, "/api/selection", { drawingId: doc.id, ids: ["a"] }); + server.showDrawing(PANEL, store.get(doc.id)); + assert.deepEqual(server.selection(PANEL, doc.id), ["a"]); + server.showDrawing(PANEL, store.create("Other")); + server.showDrawing(PANEL, store.get(doc.id)); + assert.deepEqual(server.selection(PANEL, doc.id), []); +}); + +test("a drawing the agent opens counts as shown once every page of the panel says so", async (t) => { + const { server, store, doc } = await setup(t); + const other = store.create("Other"); + // With no page open, the next page to load shows the panel's drawing. + assert.equal(await server.openDrawing(PANEL, other), "shown"); + assert.equal(server.ensureDrawing(PANEL).id, other.id); + + const first = await listen(t, server, "page1"); + const second = await listen(t, server, "page2"); + const answer = (clientId, switchId) => post(server, "/api/switched", { clientId, switchId }); + const soon = (promise) => Promise.race([promise, new Promise((resolve) => setTimeout(() => resolve("waiting"), 100))]); + const opened = async (drawing, timeoutMs) => { + const result = server.openDrawing(PANEL, drawing, timeoutMs); + const ev = await first.next("switch"); + assert.equal(ev.drawing.id, drawing.id); + assert.equal((await second.next("switch")).switchId, ev.switchId); + return { result, switchId: ev.switchId }; + }; + + let { result, switchId } = await opened(doc); + await answer("page1", switchId); + assert.equal(await soon(result), "waiting"); + await answer("page2", switchId); + assert.equal(await result, "shown"); + + // A page that cannot take it moves the panel back, and says why it does. + ({ result, switchId } = await opened(other)); + assert.equal(server.ensureDrawing(PANEL).id, other.id); + await post(server, "/api/drawings", { clientId: "page1", action: "open", id: doc.id, switchId }); + assert.equal(await result, "refused"); + assert.equal(server.ensureDrawing(PANEL).id, doc.id); + + // A page that goes away cannot answer, so it is not waited for. + ({ result, switchId } = await opened(other)); + await answer("page1", switchId); + second.close(); + assert.equal(await result, "shown"); +}); + +test("a drawing the agent opens is unconfirmed when the page does not answer in time", async (t) => { + const { server, store } = await setup(t); + const page = await listen(t, server, "page1"); + const result = server.openDrawing(PANEL, store.create("Other"), 200); + const { switchId } = await page.next("switch"); + assert.equal(await result, "unconfirmed"); + // A late answer changes nothing. + assert.deepEqual((await post(server, "/api/switched", { clientId: "page1", switchId })).json, { ok: true }); +}); + +test("a page's older change that arrives after a newer one is dropped", async (t) => { + const { server, store, doc } = await setup(t); + const send = (clientId, seq, ops) => post(server, "/api/ops", { clientId, drawingId: doc.id, baseRev: 0, seq, ops }); + const x = () => store.get(doc.id).elements[0].x; + + // The page closed while change 1 was still on its way, and its last change, 2, got there first. + assert.equal((await send("page", 2, { upserts: [rect("a", 200)] })).status, 200); + const late = await send("page", 1, { upserts: [rect("a", 100)] }); + assert.equal(late.status, 200); + assert.equal(late.json.ignored, true); + assert.equal(late.json.drawing.elements[0].x, 200); + assert.equal(x(), 200); + assert.equal(store.get(doc.id).rev, 1); + + // Each page counts on its own. + assert.equal((await send("other-page", 1, { upserts: [rect("a", 300)] })).json.ignored, undefined); + assert.equal(x(), 300); + + // A change that is refused does not count, so it cannot hide an older one. + const tooMany = Array.from({ length: 5001 }, (_, i) => rect(`r${i}`)); + assert.equal((await send("page", 4, { upserts: tooMany })).status, 400); + assert.equal((await send("page", 3, { upserts: [rect("a", 400)] })).json.ignored, undefined); + assert.equal(x(), 400); +}); + +test("a page gets the saved drawing back when saving tidied up what it sent", async (t) => { + const { server, doc } = await setup(t); + let rev = 0; + const send = async (ops) => { + const res = await post(server, "/api/ops", { drawingId: doc.id, baseRev: rev, ops }); + assert.equal(res.status, 200); + rev = res.json.rev; + return res.json; + }; + const byId = (res, id) => res.drawing.elements.find((e) => e.id === id); + + // Saved just as sent, whatever order the keys are in, so nothing comes back. + assert.deepEqual(await send({ upserts: [rect("a"), rect("b", 200)] }), { rev: 1 }); + const moved = Object.fromEntries(Object.entries({ ...rect("a"), x: 40 }).reverse()); + assert.deepEqual(await send({ upserts: [moved] }), { rev: 2 }); + const arrow = normalizeElement({ id: "ab", type: "arrow", from: "a", to: "b" }); + assert.deepEqual(await send({ upserts: [arrow] }), { rev: 3 }); + + const wide = await send({ upserts: [{ ...rect("a"), w: 6000 }] }); + assert.equal(wide.cleaned, true); + assert.equal(byId(wide, "a").w, 5000); + + const pen = await send({ upserts: [{ id: "p", type: "pen", points: [[0, 0], [12.34, 5.67]], color: "gray", width: 2.5 }] }); + assert.equal(pen.cleaned, true); + assert.deepEqual(byId(pen, "p").points, [[0, 0], [12.3, 5.7]]); + + // Deleting a shape but not its arrow: the arrow goes too. + const orphan = await send({ deletes: ["b"] }); + assert.equal(orphan.cleaned, true); + assert.deepEqual(orphan.drawing.elements.map((e) => e.id), ["a", "p"]); + + // A page that is behind gets the whole drawing anyway, as someone else's change. + const behind = await post(server, "/api/ops", { drawingId: doc.id, baseRev: 0, ops: { upserts: [{ ...rect("c"), w: 6000 }] } }); + assert.equal(byId(behind.json, "c").w, 5000); + assert.equal(behind.json.cleaned, undefined); +}); + +test("bad requests get a clear error", async (t) => { + const { server } = await setup(t); + for (const body of ["not json", "[1, 2]", "null"]) { + const res = await post(server, "/api/ops", body); + assert.equal(res.status, 400, body); + assert.equal(res.json.error, "Request body must be a JSON object."); + } + assert.equal((await post(server, "/api/unknown", {})).status, 404); + assert.equal((await get(server, apiPath(server, "/api/ops"))).status, 404); +}); + +test("settings only take known themes, and a failed write keeps the old theme", async (t) => { + const { server, settings } = await setup(t); + assert.equal((await post(server, "/api/settings", { theme: "neon" })).status, 400); + const saved = await post(server, "/api/settings", { theme: "light" }); + assert.equal(saved.status, 200); + assert.deepEqual(saved.json.settings, { theme: "light" }); + assert.equal(JSON.parse(await readFile(settings.file, "utf8")).theme, "light"); + + // A file where the settings folder should be makes the next write fail. + await rm(path.dirname(settings.file), { recursive: true }); + await writeFile(path.dirname(settings.file), ""); + assert.equal((await post(server, "/api/settings", { theme: "dark" })).status, 500); + assert.deepEqual(await settings.read(), { theme: "light" }); +}); + +test("drawings can be made, renamed, copied and deleted", async (t) => { + const { server, store, doc } = await setup(t); + store.replace(doc.id, [rect("a")], "test"); + const drawings = (body) => post(server, "/api/drawings", body); + + const made = await drawings({ action: "new", name: "Plan" }); + assert.equal(made.status, 200); + assert.equal(made.json.drawing.name, "Plan"); + assert.equal(store.drawingForInstance(PANEL).id, made.json.drawing.id); + + assert.equal((await drawings({ action: "rename", id: doc.id, name: " " })).status, 400); + assert.equal((await drawings({ action: "rename", id: doc.id, name: "Architecture" })).json.drawing.name, "Architecture"); + + const copy = await drawings({ action: "duplicate", id: doc.id }); + assert.equal(copy.json.drawing.name, "Architecture copy"); + assert.deepEqual(store.get(copy.json.drawing.id).elements, store.get(doc.id).elements); + + const deleted = await drawings({ action: "delete", id: doc.id }); + assert.equal(deleted.status, 200); + assert.equal(store.get(doc.id), null); + assert.ok(!deleted.json.drawings.some((d) => d.id === doc.id)); + + assert.equal((await drawings({ action: "open", id: doc.id })).status, 404); + assert.equal((await drawings({ action: "delete", id: doc.id })).status, 404); + assert.equal((await drawings({ action: "explode" })).status, 400); +}); + +test("exports are written to the drawings folder", async (t) => { + const { server, store, doc } = await setup(t); + const svg = ''; + const res = await post(server, "/api/export", { drawingId: doc.id, format: "svg", data: svg }); + assert.equal(res.status, 200); + assert.equal(path.dirname(res.json.path), store.exportsDir); + assert.equal(await readFile(res.json.path, "utf8"), svg); + assert.equal((await post(server, "/api/export", { drawingId: doc.id, format: "png", data: "" })).status, 400); + assert.equal((await post(server, "/api/export", { drawingId: "missing", format: "svg", data: svg })).status, 404); +}); + +test("an export the agent asks for is only taken from the drawing it named", async (t) => { + const { server, doc } = await setup(t); + const svg = ''; + assert.equal(server.requestExport(PANEL, doc.id, "svg"), null); + const events = await listen(t, server); + const answer = (requestId, drawingId) => post(server, "/api/export", { requestId, drawingId, format: "svg", data: svg }); + + // The panel moved to another drawing before it drew this one. + const switched = assert.rejects(server.requestExport(PANEL, doc.id, "svg"), /switched to another drawing/); + const first = await events.next("export"); + assert.equal(first.drawingId, doc.id); + assert.equal(first.format, "svg"); + assert.deepEqual((await answer(first.requestId, "another-drawing")).json, { ok: true }); + await switched; + + const drawn = server.requestExport(PANEL, doc.id, "svg"); + const second = await events.next("export"); + await answer(second.requestId, doc.id); + assert.deepEqual(await drawn, { format: "svg", data: svg }); + // An answer to a request that is already done is ignored. + assert.deepEqual((await answer(second.requestId, doc.id)).json, { ok: false }); +}); + +test("reveal refuses paths outside the drawings folder", async (t) => { + const { server, store, dir } = await setup(t); + for (const target of [dir, store.dir, path.join(store.dir, "..", "settings"), os.homedir(), ""]) { + assert.equal((await post(server, "/api/reveal", { path: target })).status, 400, target); + } +}); + +test("ask needs a question and a connected session, then sends the drawing to Copilot", async (t) => { + const ctx = await setup(t); + const { server, doc } = ctx; + assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: " " })).status, 400); + assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" })).status, 503); + + const sent = []; + ctx.session = { send: async (message) => sent.push(message) }; + const res = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this", png: "iVBORw0KGgo=" }); + assert.equal(res.status, 200); + assert.equal(sent.length, 1); + assert.equal(sent[0].displayPrompt, "Explain this"); + assert.ok(sent[0].prompt.startsWith("Explain this")); + assert.ok(sent[0].prompt.includes(`instanceId "${PANEL}"`)); + assert.deepEqual(sent[0].attachments.map((a) => a.mimeType), ["image/png"]); + + ctx.session = { + send: async () => { + throw new Error("offline"); + }, + }; + const failed = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" }); + assert.equal(failed.status, 502); + assert.match(failed.json.error, /offline/); +}); + +test("a question is only sent while its panel still shows the drawing it is about", async (t) => { + const ctx = await setup(t); + const { server, store, doc } = ctx; + const sent = []; + ctx.session = { send: async (message) => sent.push(message) }; + const other = store.create("Other"); + server.showDrawing(PANEL, other); + + const stale = await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" }); + assert.equal(stale.status, 409); + assert.match(stale.json.error, /was not sent/); + assert.equal(sent.length, 0); + + assert.equal((await post(server, "/api/ask", { drawingId: other.id, text: "Explain this" })).status, 200); + assert.ok(sent[0].prompt.includes(`instanceId "${PANEL}" and drawingId "${other.id}"`)); +}); + +test("ask sends only part of a big drawing's outline, and says how to read the rest", async (t) => { + const ctx = await setup(t); + const { server, store, doc } = ctx; + // The most a drawing can hold: 5,000 shapes with 4,000 character labels. + store.replace(doc.id, Array.from({ length: 5000 }, (_, i) => ({ ...rect(`r${i}`, i * 10), text: "x".repeat(4000) })), "test"); + const sent = []; + ctx.session = { send: async (message) => sent.push(message) }; + assert.equal((await post(server, "/api/ask", { drawingId: doc.id, text: "Explain this" })).status, 200); + const prompt = sent[0].prompt; + assert.ok(prompt.length < 20000, `the prompt has ${prompt.length} characters`); + assert.match(prompt, /Drawing ".*": 5000 shapes/); + assert.match(prompt, /The outline stops after \d+ of 5000 elements\. Call get_drawing with start \d+ to read the rest\./); +}); + +test("live updates reach the other panels, not the one that made the change", async (t) => { + const { server, doc } = await setup(t); + const events = await listen(t, server, "me"); + await post(server, "/api/ops", { clientId: "me", drawingId: doc.id, ops: { upserts: [rect("a")] } }); + await post(server, "/api/ops", { clientId: "someone-else", drawingId: doc.id, ops: { upserts: [rect("b", 200)] } }); + const update = await events.next("ops"); + assert.equal(update.rev, 2); + assert.equal(update.origin, "someone-else"); +}); + +test("a change goes out as the ops that made it, even on the biggest drawing", async (t) => { + const { server, store, doc } = await setup(t); + // The most a drawing can hold: 5,000 shapes with 4,000 character labels, about 20 MB. + store.replace(doc.id, Array.from({ length: 5000 }, (_, i) => ({ ...rect(`r${i}`, i * 10), text: "x".repeat(4000) })), "test"); + const events = await listen(t, server); + const check = async (change, want, maxSize = 10000) => { + const before = store.get(doc.id).elements; + await change(); + const update = await events.next("ops"); + const size = JSON.stringify(update).length; + assert.ok(size < maxSize, `the update has ${size} characters`); + assert.equal(update.drawingId, doc.id); + assert.equal(update.baseRev, update.rev - 1); + assert.equal(update.rev, store.get(doc.id).rev); + assert.deepEqual(update.ops, want); + // The ops turn the drawing as it was into the drawing as it is. + const after = update.ops ? mergeOps(before, update.ops) : before; + assert.deepEqual(after, store.get(doc.id).elements); + }; + + // From a page. The shape it sent past the size limit goes out as it was saved. + const wide = { ...rect("new", 50), w: 99999 }; + await check(() => post(server, "/api/ops", { clientId: "page", drawingId: doc.id, ops: { upserts: [wide], deletes: ["r0"] } }), { + upserts: [{ ...wide, w: 5000 }], + deletes: ["r0"], + }); + // From the agent: a new label, and the last shape moved to the back, which sends the order + // (5,000 ids, still far less than the drawing). + await check( + () => store.mutate(doc.id, (els) => [els.at(-1), ...els.slice(0, -1).map((e) => (e.id === "r7" ? { ...e, text: "seven" } : e))]), + { upserts: [{ ...store.get(doc.id).elements.find((e) => e.id === "r7"), text: "seven" }], deletes: [], order: ["new", ...store.get(doc.id).elements.slice(0, -1).map((e) => e.id)] }, + 100000, + ); + // A rename changes no element. + await check(() => store.rename(doc.id, "Renamed"), null); +}); + +test("a drawing that cannot be saved is reported to its panel until a save works", async (t) => { + const { server, store, doc } = await setup(t); + await store.flush(); + const events = await listen(t, server); + // A folder where the drawing's file should be makes every write fail. + const file = store.filePath(doc.id); + await rm(file, { force: true }); + await mkdir(file); + await writeFile(path.join(file, "keep"), ""); + + store.replace(doc.id, [rect("a")], "test"); + await assert.rejects(store.flush(), { code: "save_failed" }); + const failed = await events.next("save"); + assert.equal(failed.drawingId, doc.id); + assert.ok(failed.error); + assert.equal((await get(server, apiPath(server, "/api/state"))).json.drawing.saveError, failed.error); + + await rm(file, { recursive: true, force: true }); + await store.flush(); + assert.deepEqual(await events.next("save"), { drawingId: doc.id, error: null }); + assert.equal((await get(server, apiPath(server, "/api/state"))).json.drawing.saveError, null); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")).elements.map((e) => e.id), ["a"]); +}); diff --git a/extensions/draw/settings.mjs b/extensions/draw/settings.mjs new file mode 100644 index 000000000..51a11cb19 --- /dev/null +++ b/extensions/draw/settings.mjs @@ -0,0 +1,66 @@ +// User preferences for the Draw canvas (for now, just the theme). Unlike drawings, which stay in +// their session, these apply to every session: /draw/settings.json. +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export const THEMES = ["app", "light", "dark"]; +const DEFAULTS = { theme: "app" }; + +export function settingsFile() { + const home = process.env.COPILOT_HOME || path.join(os.homedir(), ".copilot"); + return path.join(home, "draw", "settings.json"); +} + +export class Settings { + // Without a file, settings live in memory only. + constructor(file = null, { log = () => {} } = {}) { + this.file = file; + this.log = log; + this.values = { ...DEFAULTS }; + // What the file holds, so a failed write can put the in-memory copy back to match it. + this.saved = this.values; + this.writing = Promise.resolve(); + } + + // Reads the file every time, so a change made in another session shows up on the next load. + async read() { + if (this.file) { + try { + const raw = JSON.parse(await fs.readFile(this.file, "utf8")); + if (THEMES.includes(raw.theme)) this.values = this.saved = { ...this.values, theme: raw.theme }; + } catch (err) { + if (err.code !== "ENOENT") this.log(`could not read ${this.file}: ${err.message}`); + } + } + return { ...this.values }; + } + + // Resolves once the file is written, and rejects (leaving the old values in place) if it cannot be. + async update(patch) { + if (patch.theme !== undefined && !THEMES.includes(patch.theme)) { + throw new Error(`Theme must be one of: ${THEMES.join(", ")}.`); + } + const next = { ...this.values, ...(patch.theme !== undefined && { theme: patch.theme }) }; + this.values = next; + if (this.file) { + const data = `${JSON.stringify(next, null, 2)}\n`; + const write = this.writing.then(async () => { + await fs.mkdir(path.dirname(this.file), { recursive: true }); + await fs.writeFile(this.file, data); + this.saved = next; + }); + // Only this update fails, so a later one still gets its turn to write. + this.writing = write.catch(() => {}); + try { + await write; + } catch (err) { + // Leave a newer update alone, since it has its own write coming. + if (this.values === next) this.values = this.saved; + this.log(`could not save ${this.file}: ${err.message}`); + throw err; + } + } + return { ...next }; + } +} diff --git a/extensions/draw/store.mjs b/extensions/draw/store.mjs new file mode 100644 index 000000000..bab522039 --- /dev/null +++ b/extensions/draw/store.mjs @@ -0,0 +1,414 @@ +// Drawings live as JSON files in the session workspace: /.json +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { normalizeElements, mergeOps, diffOps, MAX_ELEMENTS } from "./lib/model.mjs"; + +const VERSION = 1; +const STATE_FILE = ".state.json"; +const MAX_INSTANCES = 50; +const MAX_SLUG = 48; +const SLUG_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +// An error with a code for agent actions and an HTTP status for the canvas API. +export class StoreError extends Error { + constructor(code, message, status = 400) { + super(message); + this.code = code; + this.status = status; + } +} + +export function slugifyName(name) { + return String(name || "") + .toLowerCase() + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SLUG) + .replace(/-+$/g, ""); +} + +function cleanName(name) { + return typeof name === "string" ? name.replace(/\s+/g, " ").trim().slice(0, 80) : ""; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let tmpCount = 0; + +async function atomicWrite(file, data) { + // The count keeps two writes of one file in the same millisecond from sharing a temp file. + const tmp = `${file}.${process.pid}.${Date.now().toString(36)}.${(tmpCount++).toString(36)}.tmp`; + try { + await fs.writeFile(tmp, data, "utf8"); + for (let attempt = 0; ; attempt++) { + try { + await fs.rename(tmp, file); + return; + } catch (err) { + // Windows can briefly lock a file that an antivirus or indexer is reading. + if (attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err; + await sleep(40 * (attempt + 1)); + } + } + } catch (err) { + // A write that fails part way (a full disk, say) leaves part of the temp file, and every + // retry picks a new name, so remove it. + await fs.rm(tmp, { force: true }).catch(() => {}); + throw err; + } +} + +// File system errors name the temp file, which changes on every try, so keep just "CODE: description". +// Otherwise each retry would read as a new problem and warn the user again. +function saveProblem(err) { + const message = String(err?.message || err); + return err?.code && err.syscall ? message.split(", ")[0] : message; +} + +function serialize(doc) { + const { elements, ...meta } = doc; + const head = JSON.stringify(meta, null, 2).replace(/\n}$/, ""); + const body = elements.length + ? `[\n${elements.map((e) => " " + JSON.stringify(e)).join(",\n")}\n ]` + : "[]"; + return `${head},\n "elements": ${body}\n}\n`; +} + +export class DrawingStore extends EventEmitter { + constructor(dir, { log = () => {} } = {}) { + super(); + this.dir = dir; + this.exportsDir = path.join(dir, "exports"); + this.log = log; + this.docs = new Map(); + this.timers = new Map(); + this.saving = new Map(); + this.saveErrors = new Map(); + this.stateTimer = null; + // State writes run one at a time, so an older one cannot finish after a newer one. + this.stateSaving = Promise.resolve(); + this.listTimer = null; + this.state = { lastOpened: null, instances: {} }; + this.ready = this.#load(); + } + + async #load() { + await fs.mkdir(this.dir, { recursive: true }); + const names = await fs.readdir(this.dir).catch(() => []); + for (const name of names) { + if (!name.endsWith(".json") || name.startsWith(".")) continue; + const id = name.slice(0, -5); + // Ids made before the length fix could run a little past MAX_SLUG, so allow some slack. + if (!SLUG_ID.test(id) || id.length > MAX_SLUG + 16) continue; + try { + const raw = JSON.parse(await fs.readFile(path.join(this.dir, name), "utf8")); + if (Array.isArray(raw?.elements) && raw.elements.length > MAX_ELEMENTS) { + this.log(`Draw: ${name} has ${raw.elements.length} elements, so only the first ${MAX_ELEMENTS} were loaded.`); + } + this.docs.set(id, this.#clean(raw, id)); + } catch (err) { + this.log(`Draw: skipping unreadable drawing ${name}: ${err.message}`); + } + } + try { + const raw = JSON.parse(await fs.readFile(path.join(this.dir, STATE_FILE), "utf8")); + if (raw && typeof raw === "object") { + this.state.lastOpened = typeof raw.lastOpened === "string" ? raw.lastOpened : null; + if (raw.instances && typeof raw.instances === "object") this.state.instances = { ...raw.instances }; + } + } catch { + // No state yet. + } + } + + #clean(raw, id) { + const now = new Date().toISOString(); + return { + version: VERSION, + id, + name: cleanName(raw?.name) || id, + rev: Number.isInteger(raw?.rev) && raw.rev >= 0 ? raw.rev : 0, + createdAt: typeof raw?.createdAt === "string" ? raw.createdAt : now, + updatedAt: typeof raw?.updatedAt === "string" ? raw.updatedAt : now, + elements: normalizeElements(raw?.elements), + }; + } + + filePath(id) { + return path.join(this.dir, `${id}.json`); + } + + list() { + return [...this.docs.values()] + .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) + .map((d) => ({ id: d.id, name: d.name, updatedAt: d.updatedAt, count: d.elements.length })); + } + + get(id) { + return (typeof id === "string" && this.docs.get(id)) || null; + } + + // The drawing nameOrId means: the one with exactly that id, or else the one with that name + // (ignoring case), or else the one whose id or name has the same slug ("my plan!" finds "My + // Plan"). An exact id always wins, so it is the way to pick one of several drawings that share + // a name. When a name could mean more than one drawing, this throws instead of guessing. + find(nameOrId) { + const raw = cleanName(nameOrId); + if (!raw) return null; + const byId = this.docs.get(raw); + if (byId) return byId; + const docs = [...this.docs.values()]; + const only = (matches) => { + if (matches.length > 1) { + const list = matches.map((d) => `${d.id} ("${d.name}")`).join(", "); + throw new StoreError("ambiguous_name", `More than one drawing matches "${raw}": ${list}. Pass the id of the one you want.`); + } + return matches[0] || null; + }; + const lower = raw.toLowerCase(); + const byName = only(docs.filter((d) => d.name.toLowerCase() === lower)); + if (byName) return byName; + const slug = slugifyName(raw); + return slug ? only(docs.filter((d) => d.id === slug || slugifyName(d.name) === slug)) : null; + } + + #nextName() { + const taken = new Set([...this.docs.values()].map((d) => d.name.toLowerCase())); + let n = this.docs.size + 1; + while (taken.has(`drawing ${n}`)) n++; + return `Drawing ${n}`; + } + + create(name) { + const finalName = cleanName(name) || this.#nextName(); + const root = slugifyName(finalName) || "drawing"; + let id = root; + for (let i = 2; this.docs.has(id); i++) { + const suffix = `-${i}`; + id = root.slice(0, MAX_SLUG - suffix.length).replace(/-+$/, "") + suffix; + } + const now = new Date().toISOString(); + const doc = { version: VERSION, id, name: finalName, rev: 0, createdAt: now, updatedAt: now, elements: [] }; + this.docs.set(id, doc); + this.#scheduleSave(id, 0); + // Soon rather than now, so a copy is listed once its elements are in. + this.#listSoon(); + return doc; + } + + #require(id) { + const doc = this.get(id); + if (!doc) throw new Error(`No drawing with id "${id}".`); + return doc; + } + + // `ops` are the element-level ops that made the change (see diffOps), or null when no + // element changed. + #touch(doc, origin, ops = null) { + doc.rev += 1; + doc.updatedAt = new Date().toISOString(); + this.#scheduleSave(doc.id); + this.emit("change", doc, origin, ops); + this.#listSoon(); + } + + // An edit changes the drawing's count and time in the drawings list, so the list goes out + // again, once for a burst of edits rather than once per edit. + #listSoon() { + if (this.listTimer) return; + this.listTimer = setTimeout(() => { + this.listTimer = null; + this.emit("list"); + }, 300); + this.listTimer.unref?.(); + } + + rename(id, name) { + const doc = this.#require(id); + const finalName = cleanName(name); + if (!finalName) throw new Error("A drawing name cannot be empty."); + if (finalName === doc.name) return doc; + doc.name = finalName; + this.#touch(doc, "rename"); + return doc; + } + + // A change that would go over MAX_ELEMENTS fails with an error, instead of the extra + // elements quietly disappearing. + mutate(id, fn, origin = "agent") { + const doc = this.#require(id); + const next = normalizeElements(fn(doc.elements), Infinity); + if (next.length > MAX_ELEMENTS) { + const fmt = (n) => n.toLocaleString("en-US"); + throw new StoreError( + "too_many_elements", + `A drawing can hold up to ${fmt(MAX_ELEMENTS)} elements, and this change would make ${fmt(next.length)}.`, + ); + } + const ops = diffOps(new Map(doc.elements.map((e) => [e.id, e])), next); + doc.elements = next; + this.#touch(doc, origin, ops); + return doc; + } + + applyOps(id, ops, origin) { + return this.mutate(id, (elements) => mergeOps(elements, ops), origin); + } + + replace(id, elements, origin) { + return this.mutate(id, () => elements, origin); + } + + async remove(id) { + const doc = this.#require(id); + clearTimeout(this.timers.get(id)); + this.timers.delete(id); + this.docs.delete(id); + await (this.saving.get(id) || Promise.resolve()).catch(() => {}); + try { + await fs.rm(this.filePath(id), { force: true }); + } catch (err) { + // The file is still there, so keep the drawing rather than have it come back after a restart. + if (!this.docs.has(id)) this.docs.set(id, doc); + this.#scheduleSave(id); + this.emit("list"); + throw new StoreError("delete_failed", `Could not delete "${doc.name}": ${err.message}`, 500); + } + this.saving.delete(id); + this.saveErrors.delete(id); + if (this.state.lastOpened === id) this.state.lastOpened = null; + for (const [inst, drawing] of Object.entries(this.state.instances)) { + if (drawing === id) delete this.state.instances[inst]; + } + this.#scheduleState(); + this.emit("list"); + } + + drawingForInstance(instanceId) { + return this.get(this.state.instances[instanceId]); + } + + bindInstance(instanceId, drawingId) { + const instances = this.state.instances; + delete instances[instanceId]; + instances[instanceId] = drawingId; + const keys = Object.keys(instances); + for (const key of keys.slice(0, Math.max(0, keys.length - MAX_INSTANCES))) delete instances[key]; + this.state.lastOpened = drawingId; + this.#scheduleState(); + } + + get lastOpened() { + return this.get(this.state.lastOpened); + } + + async writeExport(drawingId, ext, data) { + await fs.mkdir(this.exportsDir, { recursive: true }); + const file = path.join(this.exportsDir, `${drawingId}.${ext}`); + await atomicWrite(file, data); + return file; + } + + #scheduleSave(id, delay = 200) { + clearTimeout(this.timers.get(id)); + this.timers.set(id, setTimeout(() => this.#save(id), delay)); + } + + #save(id) { + this.timers.delete(id); + const run = (this.saving.get(id) || Promise.resolve()) + .catch(() => {}) + .then(async () => { + const doc = this.docs.get(id); + if (doc) await atomicWrite(this.filePath(id), serialize(doc)); + }) + .then( + () => this.#saved(id, null), + (err) => { + this.#saved(id, err); + throw err; + }, + ); + run.catch(() => {}); + this.saving.set(id, run); + return run; + } + + // Tracks failed writes: panels get a "save" event when a drawing starts or stops failing, and + // the write is retried until the disk takes it, since the edit only exists in memory until then. + #saved(id, err) { + const failure = this.saveErrors.get(id); + if (!err) { + if (failure) { + this.saveErrors.delete(id); + this.log(`Draw: saved ${id} again.`); + this.emit("save", id, null); + } + return; + } + const attempts = (failure ? failure.attempts : 0) + 1; + const message = saveProblem(err); + this.saveErrors.set(id, { message, attempts }); + if (!failure || failure.message !== message) { + this.log(`Draw: failed to save ${id}: ${err.message}`); + this.emit("save", id, message); + } + if (this.docs.has(id) && !this.timers.has(id)) { + const timer = setTimeout(() => this.#save(id), Math.min(30000, 1000 * 2 ** (attempts - 1))); + timer.unref?.(); + this.timers.set(id, timer); + } + } + + // Why the latest write of a drawing failed, or null when it is safely on disk. + saveError(id) { + return this.saveErrors.get(id)?.message || null; + } + + // Writes a drawing now instead of after the usual short wait, and waits for the write. + // Resolves with the reason it failed, or null once the drawing is on disk. + async persist(id) { + if (this.timers.has(id)) { + clearTimeout(this.timers.get(id)); + this.#save(id); + } + await this.saving.get(id)?.catch(() => {}); + return this.saveError(id); + } + + #scheduleState() { + clearTimeout(this.stateTimer); + this.stateTimer = setTimeout(() => this.#saveState(), 300); + } + + #saveState() { + this.stateTimer = null; + // Each write takes the state as it is when its turn comes, so the last one has every change. + this.stateSaving = this.stateSaving + .then(() => atomicWrite(path.join(this.dir, STATE_FILE), JSON.stringify(this.state, null, 2) + "\n")) + .catch((err) => this.log(`Draw: failed to save state: ${err.message}`)); + return this.stateSaving; + } + + async flush() { + const pending = [...this.timers.keys()]; + for (const id of pending) { + clearTimeout(this.timers.get(id)); + this.#save(id); + } + const results = await Promise.allSettled([...this.saving.values()]); + if (this.stateTimer) { + clearTimeout(this.stateTimer); + this.#saveState(); + } + // Also waits for a state write that had already started. + await this.stateSaving; + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length) { + const what = failed.length === 1 ? "a drawing" : `${failed.length} drawings`; + throw new StoreError("save_failed", `Could not save ${what}: ${failed[0].reason.message}`, 500); + } + } +} diff --git a/extensions/draw/store.test.mjs b/extensions/draw/store.test.mjs new file mode 100644 index 000000000..0866f9ed5 --- /dev/null +++ b/extensions/draw/store.test.mjs @@ -0,0 +1,225 @@ +// Tests for how drawings are kept on disk: ids, the element limit, loading, and failed writes. +// Run `node --test` in the extension folder. +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; +import { MAX_ELEMENTS } from "./lib/model.mjs"; +import { DrawingStore, StoreError } from "./store.mjs"; + +const rect = (id) => ({ id, type: "rect", x: 0, y: 0, w: 100, h: 60, text: id }); + +// A store on a temp folder. When the test ends, pending writes finish and the folder is removed. +async function openStore(t) { + const dir = await mkdtemp(path.join(os.tmpdir(), "draw-store-test-")); + const store = new DrawingStore(dir); + await store.ready; + t.after(async () => { + await store.flush().catch(() => {}); + await rm(dir, { recursive: true, force: true, maxRetries: 3 }); + }); + return store; +} + +// Puts a folder where a drawing's file should be, so writing or deleting the file fails. +async function blockFile(file) { + await rm(file, { force: true }); + await mkdir(file); + await writeFile(path.join(file, "keep"), ""); +} + +test("a failed save warns once, keeps retrying and clears when a write works", async (t) => { + const store = await openStore(t); + const doc = store.create("Plan"); + await store.flush(); + const events = []; + store.on("save", (id, error) => events.push({ id, error })); + const file = store.filePath(doc.id); + await blockFile(file); + + store.replace(doc.id, [rect("a")], "test"); + // Each flush is one more failed try. The message stays the same, so the user is only warned once. + await assert.rejects(store.flush(), (err) => err instanceof StoreError && err.code === "save_failed" && err.status === 500); + await assert.rejects(store.flush(), { code: "save_failed" }); + assert.equal(events.length, 1); + assert.equal(events[0].id, doc.id); + assert.equal(store.saveError(doc.id), events[0].error); + assert.ok(!events[0].error.includes(".tmp"), `the message names the temp file: ${events[0].error}`); + + await rm(file, { recursive: true }); + await store.flush(); + assert.deepEqual(events[1], { id: doc.id, error: null }); + assert.equal(store.saveError(doc.id), null); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")).elements.map((e) => e.id), ["a"]); +}); + +test("a drawing whose file cannot be deleted is kept", async (t) => { + const store = await openStore(t); + const doc = store.create("Plan"); + await store.flush(); + const file = store.filePath(doc.id); + await blockFile(file); + + await assert.rejects(store.remove(doc.id), { code: "delete_failed", status: 500 }); + assert.equal(store.get(doc.id), doc); + assert.deepEqual(store.list().map((d) => d.id), [doc.id]); + await rm(file, { recursive: true }); +}); + +test("ids are short, unique and safe to use as file names", async (t) => { + const store = await openStore(t); + const name = "A very long drawing name ".repeat(8); + const ids = Array.from({ length: 12 }, () => store.create(name).id); + assert.equal(new Set(ids).size, ids.length); + for (const id of ids) { + assert.ok(id.length <= 48, id); + assert.match(id, /^[a-z0-9]+(?:-[a-z0-9]+)*$/); + } + assert.equal(store.create("Ünïcödé / name?").id, "unicode-name"); + assert.equal(store.create("???").id, "drawing"); +}); + +test("changes past the element limit are refused and leave the drawing alone", async (t) => { + const store = await openStore(t); + const doc = store.create("Plan"); + store.replace(doc.id, [rect("a")], "test"); + const many = Array.from({ length: MAX_ELEMENTS + 1 }, (_, i) => rect(`r${i}`)); + assert.throws(() => store.replace(doc.id, many, "test"), { code: "too_many_elements", status: 400 }); + assert.equal(doc.rev, 1); + assert.deepEqual(doc.elements.map((e) => e.id), ["a"]); + store.replace(doc.id, many.slice(0, MAX_ELEMENTS), "test"); + assert.equal(doc.elements.length, MAX_ELEMENTS); +}); + +test("edits send the drawing list out again, once for a burst of them", async (t) => { + const store = await openStore(t); + const lists = []; + store.on("list", () => lists.push(store.list())); + + // A new drawing is listed once its elements are in, so a copy never shows up empty. + const doc = store.create("Plan"); + store.replace(doc.id, [rect("a"), rect("b")], "test"); + assert.equal(lists.length, 0); + await sleep(400); + assert.deepEqual(lists.map((list) => list[0].count), [2]); + + store.applyOps(doc.id, { upserts: [rect("c")] }, "user"); + store.applyOps(doc.id, { deletes: ["a"] }, "user"); + store.rename(doc.id, "Architecture"); + await sleep(400); + assert.equal(lists.length, 2); + assert.deepEqual(lists[1], [{ id: doc.id, name: "Architecture", updatedAt: doc.updatedAt, count: 2 }]); +}); + +test("drawings load back from disk, and unreadable files are skipped", async (t) => { + const store = await openStore(t); + const doc = store.create("Plan"); + store.replace(doc.id, [rect("a"), rect("b")], "test"); + store.bindInstance("panel", doc.id); + await store.flush(); + await writeFile(path.join(store.dir, "broken.json"), "{ not json"); + await writeFile(path.join(store.dir, "Not A Slug.json"), JSON.stringify({ elements: [] })); + + const logs = []; + const again = new DrawingStore(store.dir, { log: (message) => logs.push(message) }); + await again.ready; + assert.deepEqual(again.list().map((d) => d.id), [doc.id]); + assert.deepEqual(again.get(doc.id).elements, doc.elements); + assert.equal(again.drawingForInstance("panel").id, doc.id); + assert.ok(logs.some((message) => message.includes("broken.json"))); +}); + +test("exports of one drawing written in the same millisecond all finish", async (t) => { + const store = await openStore(t); + // Temp file names hold the time, so stop the clock. Then rename only once every temp file is + // written, one at a time: temp files that shared a name would be renamed out from under each other. + t.mock.method(Date, "now", () => 1700000000000); + const rename = fs.rename; + let waiting = 0; + let release; + let queue = new Promise((resolve) => (release = resolve)); + t.mock.method(fs, "rename", (...args) => { + if (++waiting === 3) release(); + const done = queue.then(() => rename(...args)); + queue = done.catch(() => {}); + return done; + }); + const payloads = ["", "", ""]; + const files = await Promise.all(payloads.map((data) => store.writeExport("plan", "svg", data))); + assert.equal(new Set(files).size, 1); + assert.ok(payloads.includes(await readFile(files[0], "utf8"))); +}); + +test("a save that fails part way through writing leaves no temp file behind", async (t) => { + const store = await openStore(t); + const doc = store.create("Plan"); + await store.flush(); + // Like a full disk: the temp file gets part of the drawing, and then the write fails. + const write = fs.writeFile; + const failing = t.mock.method(fs, "writeFile", async (file, data, ...rest) => { + if (!String(file).endsWith(".tmp")) return write(file, data, ...rest); + await write(file, String(data).slice(0, 10), ...rest); + throw Object.assign(new Error(`ENOSPC: no space left on device, write '${file}'`), { code: "ENOSPC", syscall: "write" }); + }); + const temps = async () => (await readdir(store.dir)).filter((name) => name.endsWith(".tmp")); + + store.replace(doc.id, [rect("a")], "test"); + await assert.rejects(store.flush(), { code: "save_failed" }); + await assert.rejects(store.flush(), { code: "save_failed" }); + assert.deepEqual(await temps(), []); + + failing.mock.restore(); + await store.flush(); + assert.deepEqual(await temps(), []); + assert.deepEqual(JSON.parse(await readFile(store.filePath(doc.id), "utf8")).elements.map((e) => e.id), ["a"]); +}); + +test("state writes go one at a time, and flush waits for one that has already started", async (t) => { + const store = await openStore(t); + const a = store.create("A"); + const b = store.create("B"); + await store.flush(); + // Holds the next rename of the state file until it is released, like a file an antivirus is reading. + const rename = fs.rename; + let hold = null; + t.mock.method(fs, "rename", async (from, to) => { + const h = String(to).endsWith(".state.json") ? hold : null; + if (!h) return rename(from, to); + hold = null; + h.started(); + await h.gate; + try { + return await rename(from, to); + } finally { + h.finished(); + } + }); + const holdNext = () => { + const h = {}; + h.gate = new Promise((resolve) => (h.release = resolve)); + h.waiting = new Promise((resolve) => (h.started = resolve)); + h.done = new Promise((resolve) => (h.finished = resolve)); + return (hold = h); + }; + const shown = async () => JSON.parse(await readFile(path.join(store.dir, ".state.json"), "utf8")).instances.panel; + + const first = holdNext(); + store.bindInstance("panel", a.id); + await first.waiting; + setTimeout(first.release, 50); + await store.flush(); + assert.equal(await shown(), a.id); + + // A newer binding made while an older write is held is the one left on disk. + const second = holdNext(); + store.bindInstance("panel", b.id); + await second.waiting; + store.bindInstance("panel", a.id); + setTimeout(second.release, 50); + await store.flush(); + await second.done; + assert.equal(await shown(), a.id); +}); diff --git a/plugins/draw/README.md b/plugins/draw/README.md new file mode 100644 index 000000000..7ad030d0f --- /dev/null +++ b/plugins/draw/README.md @@ -0,0 +1,17 @@ +# Draw Plugin + +Draw simple diagrams in a Copilot canvas: shapes, arrows that stay attached, text and a freehand pen. Copilot can read, create and edit the same drawing. + +## Installation + +```bash +copilot plugin install draw@awesome-copilot +``` + +## Source + +This plugin is part of [Awesome Copilot](https://github.com/github/awesome-copilot). + +## License + +MIT diff --git a/plugins/draw/plugin.json b/plugins/draw/plugin.json new file mode 100644 index 000000000..3bbf80e14 --- /dev/null +++ b/plugins/draw/plugin.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "draw", + "description": "Draw simple diagrams in a Copilot canvas: shapes, arrows that stay attached, text and a freehand pen. Copilot can read, create and edit the same drawing, tidy its layout, and export it as PNG or SVG.", + "version": "1.0.0", + "author": { + "name": "Burke Holland", + "url": "https://github.com/burkeholland" + }, + "repository": "https://github.com/github/awesome-copilot", + "license": "MIT", + "keywords": [ + "architecture-diagrams", + "canvas", + "diagrams", + "drawing", + "flowcharts", + "whiteboard" + ], + "extensions": { + "com.github.copilot": { + "logo": "assets/preview.png" + }, + "com.github.awesome-copilot": { + "extensions": [ + "./extensions/draw" + ] + } + } +}