From dc9f9da5b0e53bb06260fa9d624b0ba077035150 Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sun, 27 Sep 2026 06:33:29 -0400 Subject: [PATCH 1/4] Option turns the pointer into a pen that quotes a region of a board in the chat Held over the canvas, Option makes the pointer a pen. A drag across a board marks a region, and that part of the board goes to the chat as a picture, captioned with the board, where on it, and the elements there. Co-Authored-By: Claude Opus 5.5 --- canvas/src/ChatPanel.tsx | 33 +++++- canvas/src/canvasAttach.tsx | 37 +++--- canvas/src/canvasChrome.tsx | 37 ++++-- canvas/src/index.css | 16 +++ canvas/src/magicPen.tsx | 227 ++++++++++++++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 28 deletions(-) create mode 100644 canvas/src/magicPen.tsx diff --git a/canvas/src/ChatPanel.tsx b/canvas/src/ChatPanel.tsx index 78238a9e..0b0d82fb 100644 --- a/canvas/src/ChatPanel.tsx +++ b/canvas/src/ChatPanel.tsx @@ -100,6 +100,8 @@ const OPEN_KEY = "sp-chat-open"; * A board comes as `board`: its name and where the server draws it. Drawing takes seconds, so the * panel puts its tile and its number up at once and asks for the drawing itself — from here and * not from the canvas's frame, which a reload or a change of tab would take the answer away with. + * A region the magic pen marked (magicPen.tsx) comes with `crop`, the part of that drawing it is, + * as fractions of it: the server draws at the display's pixel ratio, which only the drawing knows. * * On `window`, because the panel is a sibling of `` and the button renders inside it. * Here rather than beside the button, so the home page, which has the panel and no canvas, has @@ -108,7 +110,12 @@ const OPEN_KEY = "sp-chat-open"; export const CANVAS_ATTACH = "sp:canvas-attach"; export type CanvasAttachDetail = - | { kind: "board"; name: string; src: string } + | { + kind: "board"; + name: string; + src: string; + crop?: { x: number; y: number; w: number; h: number }; + } | { kind: "image"; file: File } | { kind: "error"; message: string } | { kind: "draft"; text: string } @@ -816,7 +823,11 @@ export function ChatPanel(props: { * drawing asked of the server, to land in that tile. Asked for again it keeps the tile it has — * one already there or on its way is only named again, and one that failed is drawn again. */ - const addBoard = (name: string, src: string) => { + const addBoard = ( + name: string, + src: string, + crop?: { x: number; y: number; w: number; h: number }, + ) => { let tile = tray.current.find((t) => t.name === name); if (!tile && tray.current.length >= MAX_IMAGES) return setSendError( @@ -851,7 +862,21 @@ export function ChatPanel(props: { fetch(src) .then(async (shot) => { if (!shot.ok) throw new Error(await shot.text()); - const png = await shot.blob(); + let png = await shot.blob(); + if (crop) { + const whole = await createImageBitmap(png); + const [w, h] = [whole.width, whole.height]; + const part = await createImageBitmap( + whole, + Math.round(crop.x * w), + Math.round(crop.y * h), + Math.round(crop.w * w), + Math.round(crop.h * h), + ); + const canvas = new OffscreenCanvas(part.width, part.height); + canvas.getContext("2d")!.drawImage(part, 0, 0); + png = await canvas.convertToBlob({ type: "image/png" }); + } void addImages([new File([png], name, { type: png.type })], true); }) .catch((error) => { @@ -877,7 +902,7 @@ export function ChatPanel(props: { // that is still being read. if (detail.kind === "board") { adds.current = adds.current.then(() => - addBoard(detail.name, detail.src), + addBoard(detail.name, detail.src, detail.crop), ); return; } diff --git a/canvas/src/canvasAttach.tsx b/canvas/src/canvasAttach.tsx index 641c91de..0f701712 100644 --- a/canvas/src/canvasAttach.tsx +++ b/canvas/src/canvasAttach.tsx @@ -16,10 +16,7 @@ import { CANVAS_ATTACH, type CanvasAttachDetail } from "./ChatPanel"; import { personsShape, personsShapeName } from "./canvasContent"; import { canvasBoardRef } from "./canvasLibrary"; import { Plus } from "./geistIcons"; -import { - asCanvasTarget, - shapeUnderPointer, -} from "./canvasClicks"; +import { asCanvasTarget, shapeUnderPointer } from "./canvasClicks"; /** A board, a picture the library placed, or anything the person put on a project canvas: what * the **+** answers for. The canvas answers only for the first two (asCanvasTarget). */ @@ -27,9 +24,27 @@ const addable = (editor: Editor, shape: TLShape | undefined): TLShape | null => asCanvasTarget(shape) ?? (personsShape(editor, shape) ? shape! : null); /** To the agent's panel, which is the window's, outside the canvas's frame (AppShell.tsx). */ -const dispatchAttach = (detail: CanvasAttachDetail) => +// oxlint-disable-next-line react/only-export-components +export const dispatchAttach = (detail: CanvasAttachDetail) => window.parent.dispatchEvent(new CustomEvent(CANVAS_ATTACH, { detail })); +/** A board by its `/.html`, and where the server draws it. */ +// oxlint-disable-next-line react/only-export-components +export function boardShot(board: CanvasFileShape) { + const { w, h, path } = board.props; + const ref = canvasBoardRef(path); + if (!ref) throw new Error("that board has no file behind it"); + const name = `${ref.slug}/${ref.file}`; + // At most 4000 a side, as CanvasFileShapeUtil's toSvg draws one. + const scale = Math.min(1, 4000 / Math.max(w, h)); + const src = new URL( + `${import.meta.env.BASE_URL}__sp/shoot?path=${encodeURIComponent(name)}` + + `&w=${Math.max(1, Math.round(w * scale))}&h=${Math.max(1, Math.round(h * scale))}`, + window.location.href, + ).href; + return { name, src }; +} + /** * A board or a picture, handed to the chat. A board is a page in an `