From 01b8cb3ef38456941b5dccf6ee2569f5a7a1575c Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Sat, 26 Sep 2026 13:59:27 -0400 Subject: [PATCH 01/12] Drop the inspector; double-click zooms a board or image to fill the window The right inspector panel is gone, along with the in-board agent, the asset index and the SVG signatures that only it used. Double-clicking a board, an image, a video or any shape tldraw can't edit now zooms it to fill the canvas, instead of cropping or starting a text shape. A # deep link selects the board and zooms to it, and the address follows the selection. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 +- README.md | 7 +- canvas/server/boards.ts | 122 --- canvas/src/App.tsx | 353 ++----- canvas/src/CanvasFileShapeUtil.tsx | 66 +- canvas/src/ChatPanel.tsx | 6 +- canvas/src/InspectorComments.tsx | 364 ------- canvas/src/InspectorPanel.tsx | 1316 ------------------------- canvas/src/boardWatch.test.ts | 2 +- canvas/src/boardWatch.ts | 4 +- canvas/src/brand.css | 2 +- canvas/src/canvasAttach.tsx | 6 +- canvas/src/canvasChrome.tsx | 61 +- canvas/src/canvasClicks.ts | 85 ++ canvas/src/canvasIndex.ts | 5 +- canvas/src/canvasLibrary.ts | 12 +- canvas/src/cover.ts | 9 - canvas/src/geistIcons.tsx | 2 +- canvas/src/index.css | 866 +--------------- canvas/src/inspectorAgent.test.ts | 47 - canvas/src/inspectorAgent.ts | 485 --------- canvas/src/inspectorClicks.ts | 141 --- canvas/src/inspectorModel.test.ts | 340 ------- canvas/src/inspectorModel.ts | 301 ------ canvas/src/sheet.css | 4 +- canvas/src/sheetLayout.ts | 2 +- canvas/src/svgSignature.test.ts | 47 - canvas/src/svgSignature.ts | 29 - skills/sp-canvas/SKILL.md | 9 +- skills/sp-canvas/references/layout.md | 10 +- skills/sp-clone-prototype/SKILL.md | 7 +- 31 files changed, 244 insertions(+), 4472 deletions(-) delete mode 100644 canvas/src/InspectorComments.tsx delete mode 100644 canvas/src/InspectorPanel.tsx create mode 100644 canvas/src/canvasClicks.ts delete mode 100644 canvas/src/inspectorAgent.test.ts delete mode 100644 canvas/src/inspectorAgent.ts delete mode 100644 canvas/src/inspectorClicks.ts delete mode 100644 canvas/src/inspectorModel.test.ts delete mode 100644 canvas/src/inspectorModel.ts delete mode 100644 canvas/src/svgSignature.test.ts delete mode 100644 canvas/src/svgSignature.ts diff --git a/AGENTS.md b/AGENTS.md index 0d1516b7..cf86ab9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,11 +103,7 @@ Rules inside a canvas folder: behind the tokens. - Commit `assets.json` where a folder has one (three do). It is a `name → data URI` map of pre-encoded images the generator inlines, not - evidence. The canvas's inspector names a board's images by content, from - `assets/` first and `assets.json` second, so a re-encoded image that - matches neither falls back to its `alt`. It names an inline `` the - same way from `assets/icons/`, by its geometry rather than its bytes, and - hands it back as a vector asset. + evidence. - Never commit `ref-*.html` or `assets/refs/`. They hold third-party captures, the root `.gitignore` already excludes them, and the sp-clone-prototype skill rebuilds them. `spotify-ios` is the exception: its diff --git a/README.md b/README.md index d00aa3fb..86ebf4e6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ them all. Each is a real `sp-clone-prototype` run, rebuilt from measured samples with the evidence recorded for every token. Open any of them with `?canvas=`, and one board of it with `?canvas=#`. The address follows whatever is open, the page and -the board in the inspector, so the URL in the bar is always the link to share. +the selected board, so the URL in the bar is always the link to share. ### `duolingo-ios`, eight screens that are mostly picture @@ -163,8 +163,9 @@ they say. `sp paths` lists the two directories it writes, and `sp clean` removes them. Deep-link a page with `?canvas=`, and one board of it with -`?canvas=#`: it opens in the inspector with the camera on it, and -clicking any board writes that link into the address bar. Right-clicking the +`?canvas=#`: it opens selected, zoomed to fill the window, and +clicking any board writes that link into the address bar. Double-click a +board or an image to zoom it to fill the window. Right-clicking the canvas offers Force refresh; choose it after editing a `layout.json`. A board folder added after boot appears on its own. diff --git a/canvas/server/boards.ts b/canvas/server/boards.ts index c2e4be4a..339a949d 100644 --- a/canvas/server/boards.ts +++ b/canvas/server/boards.ts @@ -5,7 +5,6 @@ */ import fs from "node:fs"; import path from "node:path"; -import { svgSignature } from "../src/svgSignature.ts"; /** * A project's boards: this folder under it, one subfolder per canvas. The same folder under the @@ -45,126 +44,6 @@ export const IMAGE_MIME: Record = { */ export const THUMB_EDGE = 880; -/** - * FNV-1a 32 over a string's code units, base 36. The inspector's agent runs the same function - * over the base64 payload of each data: URI inside the board, and joins on `length:hash`. A - * plain hash rather than SHA because the agent runs in a sandboxed frame with no `crypto.subtle` - * in every deployment, and this does 3 MB in about 12 ms. - */ -function fnv1a(s: string) { - let h = 0x811c9dc5; - for (let i = 0; i < s.length; i++) - h = Math.imul(h ^ s.charCodeAt(i), 0x01000193) >>> 0; - return h.toString(36); -} - -interface AssetName { - /** Path inside the board folder, `assets/art/hero.png`, or `assets.json#key`. */ - name: string; - /** Decoded size, i.e. the file's own byte count. */ - bytes: number; -} - -/** - * What one asset file, or one assets.json, contributes to the index, remembered by the file's - * size and mtime. The index is built on every request for it, and this repo's own boards hold - * 300 MB of assets: hashing them once is a second, a stat each is nothing. - */ -const hashed = new Map< - string, - { stamp: string; keys: [string, AssetName][] } ->(); -function keysOf(file: string, read: (buf: Buffer) => [string, AssetName][]) { - const stat = fs.statSync(file); - const stamp = `${stat.size}:${stat.mtimeMs}`; - let entry = hashed.get(file); - if (entry?.stamp !== stamp) { - entry = { stamp, keys: read(fs.readFileSync(file)) }; - hashed.set(file, entry); - } - return entry.keys; -} - -/** - * `length:hash` of the base64 payload -> the source file, for every image a folder's generator - * could have inlined: `assets/**`, `assets-dark/**` and the values of `assets.json`. `refs/` is - * skipped because it holds third-party captures that are never committed. A generator that - * re-encodes on the way (a PIL resize) produces bytes that match nothing here, and the - * inspector then falls back to the image's alt text. An `.svg` file is indexed twice: by its - * bytes like any image, and as `svg:hash` of its geometry, which is how an inline `` on a - * board is keyed, since the generator rewrote its root tag on the way in. - */ -function assetIndex(folder: string): Record { - const out: Record = {}; - const add = ([key, name]: [string, AssetName]) => { - if (!(key in out)) out[key] = name; - }; - const walk = (dir: string, rel: string) => { - let entries: fs.Dirent[]; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const e of entries) { - if (e.name.startsWith(".")) continue; - const p = path.join(dir, e.name); - if (e.isDirectory()) { - if (e.name !== "refs") walk(p, `${rel}${e.name}/`); - continue; - } - if (!(path.extname(e.name).toLowerCase() in IMAGE_MIME)) continue; - const name = { name: rel + e.name, bytes: 0 }; - keysOf(p, (buf) => { - const payload = buf.toString("base64"); - const named = { ...name, bytes: buf.length }; - const keys: [string, AssetName][] = [ - [`${payload.length}:${fnv1a(payload)}`, named], - ]; - if (e.name.toLowerCase().endsWith(".svg")) - keys.push([ - `svg:${fnv1a(svgSignature(buf.toString("utf8")))}`, - named, - ]); - return keys; - }).forEach(add); - } - }; - for (const sub of ["assets", "assets-dark"]) - walk(path.join(folder, sub), `${sub}/`); - const json = path.join(folder, "assets.json"); - if (fs.existsSync(json)) { - keysOf(json, (buf) => { - const keys: [string, AssetName][] = []; - try { - const map: unknown = JSON.parse(buf.toString("utf8")); - if (map && typeof map === "object") { - for (const [key, v] of Object.entries(map)) { - if (typeof v !== "string" || !v.startsWith("data:")) continue; - const payload = v.slice(v.indexOf(",") + 1); - const pad = payload.endsWith("==") - ? 2 - : payload.endsWith("=") - ? 1 - : 0; - keys.push([ - `${payload.length}:${fnv1a(payload)}`, - { - name: `assets.json#${key}`, - bytes: Math.floor((payload.length * 3) / 4) - pad, - }, - ]); - } - } - } catch { - // a malformed assets.json names nothing; the boards still render - } - return keys; - }).forEach(add); - } - return out; -} - /** * `#` and `?` are legal in a filename but are a fragment and a query in a URL, and no encoding * survives the round trip (the request path is decoded with decodeURI, which leaves both alone). @@ -299,7 +178,6 @@ export function boardIndex( thumbnail: fs.existsSync(path.join(folder, "thumbnail.png")), brand: brandImages(folder), thumbs: [] as string[], - assets: assetIndex(folder), comments: readJson(path.join(folder, "comments.json")), // null for a canvas.json that is there but will not parse, after a merge left half // done, say. The page must not take that for no file and write over it diff --git a/canvas/src/App.tsx b/canvas/src/App.tsx index 299612ae..8d838239 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useLayoutEffect, useReducer, useRef, useState, @@ -59,13 +58,7 @@ import { CANVAS_FILE_SHAPE_TYPE, CanvasFileShapeUtil, } from "./CanvasFileShapeUtil"; -import { - ImagePanel, - InspectorClicks, - InspectorPanel, - type CanvasImagePick, -} from "./InspectorPanel"; -import { asCanvasTarget, type InspectorTarget } from "./inspectorClicks"; +import { asCanvasTarget, installDoubleClickZoom, zoomToFill } from "./canvasClicks"; import { attachToChat } from "./canvasAttach"; import { CanvasStatusBannerShapeUtil } from "./CanvasStatusBannerShapeUtil"; import { @@ -86,7 +79,6 @@ import { canvasImageKey, canvasImageRef, canvasImageUrl, - readCanvasImage, readCanvasLayout, pageNameFor, isLibraryShapeId, @@ -116,8 +108,8 @@ import { /** * A picture is the whole of its box. tldraw hit-tests an image that can carry transparency against - * its own pixels, so a mockup drawn on nothing answers nothing where it is nothing: the inspector - * stays shut and the attach buttons never appear except over the drawing itself. Here a picture is + * its own pixels, so a mockup drawn on nothing answers nothing where it is nothing: a double-click + * does not zoom and the attach buttons never appear except over the drawing itself. Here a picture is * a tile in a row of tiles, and the empty part of a tile is still that tile. */ class CanvasImageShapeUtil extends ImageShapeUtil { @@ -955,44 +947,54 @@ function relayoutCanvasLibrary(editor: Editor) { } /** - * Opens what the address names (canvasUrl.ts): the tab, `?canvas=` or the bare URL for the - * project's own view and `?brand=` for a brand kit, and after the hash a board of that page, - * `#`, which opens in the inspector. So a specific round, or one board in it, can be - * linked to or scripted against instead of relying on whichever page tldraw last persisted, and - * the bare URL is always the way in, so keep a board open across reloads by deep-linking it, not - * by leaving it on screen. A board the address names that is not on that page closes the - * inspector, so what is on screen never contradicts the address. Returns the board opened, if any. + * The shape the hash of an address names on a page: a board by its file name, a picture by its + * path inside the folder, or one of the person's own shapes by id (canvasContent.ts). Every + * picture is under assets/brand and a board is one file at the folder's root, so the kinds of name + * cannot collide and the hash does not have to say which it is. */ -/** What the inspector has open, as the address spells it: the page it belongs to, and either a - * board's file name or a picture's path inside that folder. */ -type CanvasAddress = { slug: string; name: string }; +function namedShape(editor: Editor, slug: string, name: string) { + if (name.startsWith("shape:")) return editor.getShape(name as TLShapeId); + const file = readCanvasLibrary() + .flatMap((c) => c.files) + .find((c) => c.pageSlug === slug && c.fileName === name); + return editor.getShape( + file + ? boardShapeId(editor, file.path, projectPages(editor).get(slug)) + : imageShapeId(slug, name), + ); +} -function applyCanvasFromUrl( - editor: Editor, - open: (tab: CanvasTab) => void, - show: { - board: (file: CanvasLibraryFile | null) => void; - image: (pick: CanvasImagePick) => void; - }, -) { +/** The other way: how an address names the one shape selected, if it can name it. */ +function shapeName(editor: Editor, shape: TLShape | undefined) { + const target = asCanvasTarget(shape); + if (target?.type === CANVAS_FILE_SHAPE_TYPE) + return readCanvasLibrary() + .flatMap((c) => c.files) + .find((c) => c.path === target.props.path)?.fileName; + if (target) return canvasImageRef(target.id)?.file; + return shape && personsShape(editor, shape) ? shape.id : undefined; +} + +/** + * Opens what the address names (canvasUrl.ts): the tab, `?canvas=` or the bare URL for the + * project's own view and `?brand=` for a brand kit, and after the hash a shape of that page, + * `#`, selected with the camera filling the canvas with it. So a specific round, or one + * board in it, can be linked to or scripted against instead of relying on whichever page tldraw + * last persisted, and the bare URL is always the way in. Returns whether the hash named a shape. + */ +function applyCanvasFromUrl(editor: Editor, open: (tab: CanvasTab) => void) { // Resolved first, so the bare address sets the page of the canvas it lands on. const tab = resolveTab(tabFromUrl(window.location.href)); - // Read before `open`, which writes the address from the inspector, and that is still empty. + // Read before `open`, which writes the address from the selection. const named = targetFromUrl(window.location.href); // A kit or a document is an overlay over the whole editor rather than a page of it, so there - // is no page to set and no board under the hash to go looking for. `open` shuts the inspector - // for it. - if (tab.kind !== "canvas") { + // is no page to set and no shape under the hash to go looking for. Nor is there for a project + // with nothing in it yet, which shows no page (HOME_TAB). + if (tab.kind !== "canvas" || !tab.slug) { open(tab); return false; } - // A project with nothing in it yet, which shows no page (HOME_TAB), so nothing to set. - if (!tab.slug) { - open(tab); - return false; - } - // The folder whose page the address shows. The board or picture the hash names is one of - // that folder's. + // The folder whose page the address shows. The shape the hash names is on that page. const slug = pageOf(tab); const page = editor.getPages().find((c) => c.meta.canvasSlug === slug); if (page) editor.setCurrentPage(page.id); @@ -1002,46 +1004,24 @@ function applyCanvasFromUrl( const here = editor.getCurrentPage().meta.canvasSlug; if (page) open(tab); else if (typeof here === "string") open({ kind: "canvas", slug: here }); - // One of the person's own shapes, by id (canvasContent.ts): selected, and the camera on it. - const own = - named?.startsWith("shape:") && editor.getShape(named as TLShapeId); - if (own) { - show.board(null); - editor.select(own); - requestAnimationFrame(() => - editor.zoomToSelection({ animation: { duration: 0 } }), - ); - return true; - } - const file = - readCanvasLibrary() - .flatMap((c) => c.files) - .find((c) => c.pageSlug === slug && c.fileName === named) ?? null; - // Then a picture of this page, which the address names by its path inside the folder. Every - // one of them is under assets/brand and a board is one file at the folder's root, so the two - // kinds of name cannot collide and the hash does not have to say which it is. - const found = !file && named ? readCanvasImage(slug, named) : undefined; - if (named && found) { - show.image({ - shapeId: imageShapeId(slug, named), - slug, - file: named, - ...found, - }); - return true; + const shape = named ? namedShape(editor, slug, named) : undefined; + if (!shape) { + editor.selectNone(); + return false; } - show.board(file); - return Boolean(file); + // Selected now, so the address written after this names it; the camera once tldraw has + // measured the viewport. + editor.select(shape.id); + requestAnimationFrame(() => zoomToFill(editor, shape.id, false)); + return true; } /** * Keeps the address on what is being looked at, so whatever is on screen can be shared by - * copying the URL: the tab in front, and the board or picture open in the inspector when it is - * one of that page's (an inspector left open across a page change names something of the other - * page, which the address then leaves out). `write` derives the address from those two whenever - * either changes, and never edits it in place, so the two writers cannot disagree. App calls - * `write` through `tab.open` when a tab comes forward, and directly when the inspector opens or - * closes. + * copying the URL: the tab in front, and the shape selected when it is the only one and the + * address can name it. `write` derives the address from those two whenever either changes, and + * never edits it in place, so the writers cannot disagree. App calls `write` through `tab.open` + * when a tab comes forward; the selection writes it here. * * This watches the page rather than writing from it, because the page is only one of the two * ways a canvas tab comes forward and the other is the bar. tldraw's own page changes, from a @@ -1049,36 +1029,24 @@ function applyCanvasFromUrl( * tldraw persisted that no folder claims have no slug, and leave the bar and the address as they * are. * - * Each change pushes a history entry, so Back returns to the previous one and, from there, to - * its page; a popstate applies the entry it lands on. Applying an address - * is the one time what is on screen changes without the address needing to follow, so the - * watcher skips it and every write it provokes replaces instead of pushing, since an entry - * there would be a second copy of the one just landed on. + * A change of tab pushes a history entry, so Back returns to the previous one; a change of + * selection replaces the entry, since it is not somewhere to go back to. A popstate applies the + * entry it lands on. Applying an address is the one time what is on screen changes without the + * address needing to follow, so the watchers skip it and every write it provokes replaces + * instead of pushing, since an entry there would be a second copy of the one just landed on. */ function installCanvasUrlSync( editor: Editor, tab: { active: () => CanvasTab; open: (tab: CanvasTab) => void }, - opened: () => CanvasAddress | null, - show: { - board: (file: CanvasLibraryFile | null) => void; - image: (pick: CanvasImagePick) => void; - }, ) { let applying = false; const write = (push: boolean) => { const active = tab.active(); - const open = opened(); - // Else one of the person's shapes when it alone is selected, as its link opens it. - const selected = editor.getOnlySelectedShape(); const named = - active.kind !== "canvas" - ? undefined - : open?.slug === pageOf(active) - ? open.name - : selected && personsShape(editor, selected) - ? selected.id - : undefined; + active.kind === "canvas" + ? shapeName(editor, editor.getOnlySelectedShape() ?? undefined) + : undefined; const href = urlForTab(window.location.href, active, named); // The window shows this address as its own, and the bar the tab it is on (AppShell.tsx). window.parent.spShell!.shown(tabFor(active), href); @@ -1091,7 +1059,7 @@ function installCanvasUrlSync( applying = true; let named: boolean; try { - named = applyCanvasFromUrl(editor, tab.open, show); + named = applyCanvasFromUrl(editor, tab.open); } finally { applying = false; } @@ -1114,12 +1082,22 @@ function installCanvasUrlSync( if (typeof slug !== "string" || !active || slug === active) return; tab.open({ kind: "canvas", slug }); }); + let firstSelection = true; + const stopSelection = react("selection in the address", () => { + editor.getOnlySelectedShapeId(); + if (firstSelection || applying) { + firstSelection = false; + return; + } + write(false); + }); window.addEventListener("popstate", apply); return { apply, write, uninstall: () => { stopSync(); + stopSelection(); window.removeEventListener("popstate", apply); }, }; @@ -1146,17 +1124,9 @@ function initializeCanvas(editor: Editor) { editor.selectNone(); } -/** Distance from the viewport's edge to the board the address named, in screen px. */ -const BOARD_ZOOM_INSET = 80; - export default function App() { - /** The board open in the inspector: click any board on the canvas to open it, Escape or × to close. */ - const [inspecting, setInspecting] = useState(null); - /** The brand image open in the inspector instead, when a picture was the thing clicked. */ - const [inspectingImage, setInspectingImage] = - useState(null); const [commentUser, setCommentUser] = useState(readCommentUser); - /** State rather than a ref: the inspector panel renders outside `` and needs it. */ + /** State rather than a ref: the chrome context hands it to parts that render outside ``. */ const [editor, setEditor] = useState(null); /** * The tab in front. State rather than something derived from the tldraw page, because a brand @@ -1167,18 +1137,10 @@ export default function App() { resolveTab(tabFromUrl(window.location.href)), ); const store = useLocalStore(storeOptions); - /** What the inspector has open, spelled the way the address spells it: a board by file name, - * a picture by its path inside the folder, each with the page it belongs to. For the address - * writer, which runs outside React. */ - const opened = useRef(null); - /** Writes the address from the tab in front and the inspector; installed with the editor. */ + /** Writes the address from the tab in front and the selection; installed with the editor. */ const writeUrl = useRef<(push: boolean) => void>(() => {}); /** The tab in front as of this call rather than as of the last render, for that writer. */ const active = useRef(activeTab); - /** What the address named, for the camera to go to once the inspector is beside it. */ - const zoomTo = useRef(null); - /** That board's frame on the canvas: the panel reads its report and posts its selection there. */ - const inspectorFrame = useRef(null); // A board rewritten in place is news as much as a new one: it rings green, where a new one // rings blue, until the pointer passes over it (canvasChrome.tsx). The server lists only the @@ -1201,50 +1163,6 @@ export default function App() { return () => window.removeEventListener(BOARDS_CHANGED, changed); }, []); - /** - * Opens a board in the inspector, or closes it. A pick or a close is a new address; applying - * an address is not, and passes `push: false`. - */ - const show = useCallback((file: CanvasLibraryFile | null, push: boolean) => { - opened.current = file ? { slug: file.pageSlug, name: file.fileName } : null; - setInspecting(file); - // One dock, one thing in it: a board opening takes the place of a picture and the other way. - setInspectingImage(null); - if (push) { - zoomTo.current = null; // the reader's own pick or close, so no address is left to zoom to - writeUrl.current(true); - } - }, []); - /** The same for a picture, which has an address of its own for the same reason a board does: - * it is a thing on the canvas someone will want to send to someone else. */ - const showImage = useCallback((pick: CanvasImagePick, push: boolean) => { - opened.current = { slug: pick.slug, name: pick.file }; - setInspecting(null); - setInspectingImage(pick); - if (push) { - zoomTo.current = null; - writeUrl.current(true); - } - }, []); - const onCloseInspector = useCallback(() => show(null, true), [show]); - const onPick = useCallback( - (shape: InspectorTarget) => { - if (shape.type === CANVAS_FILE_SHAPE_TYPE) { - const file = readCanvasLibrary() - .flatMap((c) => c.files) - .find((c) => c.path === shape.props.path); - if (file) show(file, true); - return; - } - // Brand material, addressed by the folder path its shape is keyed by. - const ref = canvasImageRef(shape.id); - const entry = ref && readCanvasImage(ref.slug, ref.file); - if (!ref || !entry) return; - showImage({ shapeId: shape.id, ...ref, ...entry }, true); - }, - [show, showImage], - ); - /** * Brings a tab forward. That marks it the one in front and puts it in the address, which the * window mirrors and takes the bar's chip from. It is everything a tab is except the tldraw @@ -1252,20 +1170,14 @@ export default function App() { * * Split in two because the address sync captures this one when the editor mounts and holds it * for the life of that editor, so it has to be a function whose behaviour does not depend on - * anything it closed over changing. It closes over `show` and nothing else. + * anything it closed over changing. It closes over nothing that does. */ - const showTab = useCallback( - (tab: CanvasTab) => { - const open = resolveTab(tab); - // A kit or a document covers the canvas whole, and the inspector left open beside it would - // be a dock onto a board of a page that is no longer in front. - if (open.kind !== "canvas") show(null, false); - active.current = open; - setActiveTab(open); - writeUrl.current(true); - }, - [show], - ); + const showTab = useCallback((tab: CanvasTab) => { + const open = resolveTab(tab); + active.current = open; + setActiveTab(open); + writeUrl.current(true); + }, []); /** * From a chip, a row of the bar's "+" menu, or a link on a board. @@ -1325,24 +1237,12 @@ export default function App() { const tab = tabFromUrl(href); const name = targetFromUrl(href); if (tab.kind !== "canvas" || !name) return undefined; - if (name.startsWith("shape:")) { - const shape = editor.getShape(name as TLShapeId); - return personsShape(editor, shape) ? shape : undefined; - } - const file = readCanvasLibrary() - .flatMap((c) => c.files) - .find((c) => c.pageSlug === tab.slug && c.fileName === name); - return asCanvasTarget( - editor.getShape( - file - ? boardShapeId( - editor, - file.path, - projectPages(editor).get(tab.slug), - ) - : imageShapeId(tab.slug, name), - ), - ); + const shape = namedShape(editor, tab.slug, name); + return name.startsWith("shape:") + ? personsShape(editor, shape) + ? shape + : undefined + : asCanvasTarget(shape); }); if (!targets.every(Boolean)) return false; void attachToChat(editor, targets as TLShape[]); @@ -1351,21 +1251,6 @@ export default function App() { }; }); - // The camera goes to what the address named, after the inspector has taken its share of the - // window: a layout effect, so the panel is in the DOM, and the viewport measured here because - // tldraw measures it on a throttled resize observer, up to 200ms behind, which would fit the - // board to the canvas width the panel just took. Every `show` from an address is a fresh - // object, so the effect runs even for the one already open. - useLayoutEffect(() => { - const id = zoomTo.current; - zoomTo.current = null; - if (!editor || !id) return; - const bounds = editor.getShapePageBounds(id); - if (!bounds) return; - editor.updateViewportScreenBounds(editor.getContainer()); - editor.zoomToBounds(bounds, { inset: BOARD_ZOOM_INSET }); - }, [inspecting, inspectingImage, editor]); - function handleMount(editor: Editor) { setEditor(editor); // tldraw's own dark theme, to match the panel's. A dark rail against tldraw's near-white ground @@ -1376,27 +1261,10 @@ export default function App() { // since a comment can be pinned to a shape the person put there. const disposeContent = installCanvasContent(editor); const disposeComments = installCanvasComments(editor); - const sync = installCanvasUrlSync( - editor, - { active: () => active.current, open: showTab }, - () => opened.current, - { - board: (file) => { - zoomTo.current = file - ? boardShapeId( - editor, - file.path, - projectPages(editor).get(file.pageSlug), - ) - : null; - show(file, false); - }, - image: (pick) => { - zoomTo.current = pick.shapeId; - showImage(pick, false); - }, - }, - ); + const sync = installCanvasUrlSync(editor, { + active: () => active.current, + open: showTab, + }); writeUrl.current = sync.write; // The address names one of them, or the whole page is the view. Not on a reload, which is // mostly the server's answer to a board the agent just wrote: tldraw has already put back @@ -1408,9 +1276,11 @@ export default function App() { )?.type === "reload"; if (!sync.apply() && !reloaded) requestAnimationFrame(() => editor.zoomToFit()); + const disposeZoom = installDoubleClickZoom(editor); return () => { disposeContent(); disposeComments(); + disposeZoom(); sync.uninstall(); }; } @@ -1424,14 +1294,8 @@ export default function App() { editor, commentUser, setCommentUser, - inspectBoard: onPick, - inspectingPath: inspecting?.path ?? null, - inspectorOpen: Boolean(inspecting || inspectingImage), activeTab, openTab, - setInspectorFrame: (frame: HTMLIFrameElement | null) => { - inspectorFrame.current = frame; - }, }} > {/* The project's side of the window: its canvases across the top, then the canvas. The bar @@ -1457,8 +1321,7 @@ export default function App() { tools={canvasCommentTools} overrides={canvasUiOverrides} // Every board and picture is locked (below, and the library's own placement) so a - // pan can't drag one and a click opens the inspector instead of tldraw's own - // selection — but by default tldraw also drops locked shapes from a marquee drag + // pan can't drag one — but by default tldraw also drops locked shapes from a marquee drag // entirely, which is the one thing this option turns back on. The lock itself is // what keeps them from moving: `updateShapes` skips a locked shape's own partial // regardless of this flag, so a selected board still can't be dragged or resized. @@ -1480,12 +1343,6 @@ export default function App() { - @@ -1518,24 +1375,6 @@ export default function App() { )} - {inspecting ? ( - // Keyed by path: a different board is a fresh panel, with its own selection and - // report, rather than one that resets its state in an effect. - - ) : inspectingImage ? ( - - ) : null} diff --git a/canvas/src/CanvasFileShapeUtil.tsx b/canvas/src/CanvasFileShapeUtil.tsx index 927efc6e..b83acd63 100644 --- a/canvas/src/CanvasFileShapeUtil.tsx +++ b/canvas/src/CanvasFileShapeUtil.tsx @@ -1,4 +1,4 @@ -import { useContext, useMemo, type CSSProperties } from "react"; +import type { CSSProperties } from "react"; import { BaseBoxShapeUtil, FileHelpers, @@ -8,7 +8,6 @@ import { type TLShape, useIsEditing, } from "tldraw"; -import { CanvasChromeContext } from "./canvasChrome"; import { local } from "./canvasIndex"; import { CANVAS_FILE_DEFAULT_SIZE, @@ -16,7 +15,6 @@ import { hasCanvasFile, useCanvasFileHtml, } from "./canvasLibrary"; -import { injectAgent } from "./inspectorAgent"; export const CANVAS_FILE_SHAPE_TYPE = "canvas-file" as const; @@ -36,28 +34,12 @@ export type CanvasFileShape = TLShape; // oxlint-disable-next-line react/only-export-components function CanvasFile({ shape }: { shape: CanvasFileShape }) { const isEditing = useIsEditing(shape.id); - const { inspectingPath, setInspectorFrame } = useContext(CanvasChromeContext); const html = useCanvasFileHtml(shape.props.path); - /** - * The board the inspector has open runs the agent (inspectorAgent.ts), so a click on the mockup - * out here picks the element under it. The panel used to load a second copy of the board to do - * that, which meant reading one mockup and clicking another. - * - * The frame still never takes the pointer: inspectorClicks.ts hands the agent the canvas's own - * pointer as a board coordinate, so panning, zooming and the comment tool go on working over - * the board being read. - */ - const inspected = inspectingPath === shape.props.path; - const agentDoc = useMemo( - () => (html && inspected ? injectAgent(html) : null), - [html, inspected], - ); - - // Behind the container, which is transparent, so the frames show through it. Safari routes a + // Behind the container, which is transparent, so the frame shows through it. Safari routes a // wheel to an iframe's own scrolling area whatever pointer-events says, so a two-finger pan // over a board did nothing there, and a horizontal one chained out to the browser's back - // gesture. Behind the container neither frame is a scroll target, and the pan reaches tldraw + // gesture. Behind the container the frame is no scroll target, and the pan reaches tldraw // wherever the cursor is. tldraw's own embed shape carries this same line: // . const frame: CSSProperties = { @@ -87,42 +69,12 @@ function CanvasFile({ shape }: { shape: CanvasFileShape }) { }} > {html ? ( - <> - - {/* The scripted board is a second document: srcdoc cannot be swapped on the frame above - (Chrome drops the second navigation while the first is still pending and leaves the - frame blank), and remounting it reloaded the mockup under the very click that opened - it, which is the flash. It loads over the board instead, pixel for pixel the same - one, so the swap is invisible — and the board underneath stays loaded, so closing - the inspector shows nothing either. */} - {agentDoc ? ( -
- Comments - {rows.length || ""} -
-
- {rows.length ? ( - rows.map((row, i) => ( - - )) - ) : ( -
{`No comments on this ${subject}.`}
- )} -
- - - ); -} - -function Thread({ - editor, - row: { thread, pin }, - n, - open, - onOpen, -}: { - editor: Editor; - row: BoardThread; - n: number; - open: boolean; - onOpen: (id: TLCommentThreadId | null) => void; -}) { - const me = useContext(CanvasChromeContext).commentUser; - const comments = useThreadComments(editor, thread.id); - const [first, ...replies] = comments; - const author = openedBy(thread); - - // The last comment takes its thread with it: what would be left is a pin with nothing behind - // it. Any other one is a message leaving the conversation. - const remove = (comment: TLComment) => { - if (comments.length > 1) return deleteComment(editor, comment); - onOpen(null); - deleteThread(editor, thread); - }; - - const reply = (body: string) => { - if (!me) return; - putCommentRecords(editor, [ - createComment({ - threadId: thread.id, - pageId: thread.pageId, - authorId: me.id, - body: toRichText(body), - }), - ]); - }; - - return ( -
- - {first ? ( - remove(first)} - /> - ) : null} - {open ? ( - <> - {replies.map((comment) => ( -
- {resolveAuthor(comment.authorId).name} - {when(comment.createdAt)} - remove(comment)} - /> -
- ))} -
- - {/* Deleting is the one thing that takes someone else's words away. */} - {me?.id === thread.createdBy ? ( - - ) : null} -
- - - ) : null} -
- ); -} - -/** One comment: its words, and for whoever wrote them, the two things they can do to them. */ -function Comment({ - editor, - comment, - clamp, - actions, - onDelete, -}: { - editor: Editor; - comment: TLComment; - clamp: boolean; - /** Only while the thread is open: a collapsed row is a preview, not somewhere to edit. */ - actions: boolean; - onDelete: () => void; -}) { - const me = useContext(CanvasChromeContext).commentUser; - const [editing, setEditing] = useState(false); - // Plain text in and out, like the composer below: a comment edited here reads the same on the - // canvas, and one written there flattens to what this panel can show of it. - const text = richTextToPlaintext(comment.body, (id) => resolveAuthor(id).name); - - if (editing) { - return ( - { - setEditing(false); - editComment(editor, comment, toRichText(body)); - }} - onCancel={() => setEditing(false)} - /> - ); - } - - return ( - <> -

{text}

- {/* Someone else's words are theirs to change, here as on the canvas. */} - {actions && me?.id === comment.authorId ? ( -
- - -
- ) : null} - - ); -} - -/** - * The field. Plain text, because that is what the whole panel is. The canvas composer is a rich - * text editor, and a note typed here reads the same in both. - */ -function Composer({ - placeholder, - initial, - onPost, - onCancel, -}: { - placeholder: string; - /** The text an edit starts from. A new comment has none: it starts empty and clears on post. */ - initial?: string; - onPost: (body: string) => void; - onCancel?: () => void; -}) { - const me = useContext(CanvasChromeContext).commentUser; - const [text, setText] = useState(initial ?? ""); - - const post = () => { - const body = text.trim(); - if (!body) return; - if (initial === undefined) setText(""); - onPost(body); - }; - - return ( -
. Asking for one is the first thing a click here does, when there is no name yet. - onPointerDown={me ? undefined : () => window.dispatchEvent(new Event(ASK_COMMENT_USER))} - > - {me ? : null} -