diff --git a/README.md b/README.md index a264b01e..acf2dca7 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/src/App.tsx b/canvas/src/App.tsx index 299612ae..458cb004 100644 --- a/canvas/src/App.tsx +++ b/canvas/src/App.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, - useLayoutEffect, useReducer, useRef, useState, @@ -34,6 +33,7 @@ import { type TLTextShape, useEditor, useLocalStore, + useValue, } from "tldraw"; import "tldraw/tldraw.css"; import "@tldraw/commenting/commenting.css"; @@ -60,12 +60,13 @@ import { CanvasFileShapeUtil, } from "./CanvasFileShapeUtil"; import { - ImagePanel, - InspectorClicks, - InspectorPanel, - type CanvasImagePick, -} from "./InspectorPanel"; -import { asCanvasTarget, type InspectorTarget } from "./inspectorClicks"; + asCanvasTarget, + installBoardHover, + installDoubleClickZoom, + zoomToFill, +} from "./canvasClicks"; +import { CanvasPresent } from "./CanvasPresent"; +import { ImagePanel, InspectorPanel } from "./InspectorPanel"; import { attachToChat } from "./canvasAttach"; import { CanvasStatusBannerShapeUtil } from "./CanvasStatusBannerShapeUtil"; import { @@ -116,8 +117,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 +956,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") { - open(tab); - return false; - } - // A project with nothing in it yet, which shows no page (HOME_TAB), so nothing to set. - if (!tab.slug) { + // 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; } - // 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 +1013,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 +1038,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 +1068,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 +1091,24 @@ function installCanvasUrlSync( if (typeof slug !== "string" || !active || slug === active) return; tab.open({ kind: "canvas", slug }); }); + // `write` reads the selected shape's record, so this also runs as that shape is dragged or + // typed into. Only a different shape is a new address. + let selected: string | null | undefined; + const stopSelection = react("selection in the address", () => { + const id = editor.getOnlySelectedShapeId(); + const first = selected === undefined; + if (id === selected) return; + selected = id; + if (first || applying) return; + write(false); + }); window.addEventListener("popstate", apply); return { apply, write, uninstall: () => { stopSync(); + stopSelection(); window.removeEventListener("popstate", apply); }, }; @@ -1146,17 +1135,13 @@ 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. */ + /** The inspector, off until the strip's button turns it on. */ + const [inspectorOn, setInspectorOn] = useState(false); + /** That selected board's frame on the canvas: the panel reads its report and posts back there. */ + const inspectorFrame = useRef(null); + /** 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 +1152,16 @@ 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. */ + /** What the inspector shows while it is on: the one board or picture selected. */ + const inspected = useValue( + "inspected", + () => asCanvasTarget(editor?.getOnlySelectedShape() ?? undefined), + [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 +1184,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 +1191,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 +1258,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 +1272,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 +1282,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 +1297,13 @@ export default function App() { )?.type === "reload"; if (!sync.apply() && !reloaded) requestAnimationFrame(() => editor.zoomToFit()); + const disposeZoom = installDoubleClickZoom(editor); + const disposeHover = installBoardHover(editor); return () => { + disposeHover(); disposeContent(); disposeComments(); + disposeZoom(); sync.uninstall(); }; } @@ -1424,11 +1317,10 @@ export default function App() { editor, commentUser, setCommentUser, - inspectBoard: onPick, - inspectingPath: inspecting?.path ?? null, - inspectorOpen: Boolean(inspecting || inspectingImage), activeTab, openTab, + inspectorOn, + setInspectorOn, setInspectorFrame: (frame: HTMLIFrameElement | null) => { inspectorFrame.current = frame; }, @@ -1437,6 +1329,7 @@ export default function App() { {/* The project's side of the window: its canvases across the top, then the canvas. The bar above and the agent's panel beside are the window's (AppShell.tsx), outside this frame. */}
+
@@ -1457,8 +1350,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 +1372,6 @@ export default function App() { - @@ -1518,24 +1404,36 @@ 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} + {inspectorOn && activeTab.kind === "canvas" && inspected && editor + ? (() => { + if (inspected.type === CANVAS_FILE_SHAPE_TYPE) { + const file = readCanvasLibrary() + .flatMap((c) => c.files) + .find((c) => c.path === inspected.props.path); + // 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. + return file ? ( + editor.selectNone()} + /> + ) : null; + } + const ref = canvasImageRef(inspected.id); + const entry = ref && readCanvasImage(ref.slug, ref.file); + return ref && entry ? ( + editor.selectNone()} + /> + ) : null; + })() + : null}
diff --git a/canvas/src/CanvasFileShapeUtil.tsx b/canvas/src/CanvasFileShapeUtil.tsx index 927efc6e..5d6d0b77 100644 --- a/canvas/src/CanvasFileShapeUtil.tsx +++ b/canvas/src/CanvasFileShapeUtil.tsx @@ -1,4 +1,4 @@ -import { useContext, useMemo, type CSSProperties } from "react"; +import { useContext, type CSSProperties } from "react"; import { BaseBoxShapeUtil, FileHelpers, @@ -6,7 +6,9 @@ import { T, type RecordProps, type TLShape, + useEditor, useIsEditing, + useValue, } from "tldraw"; import { CanvasChromeContext } from "./canvasChrome"; import { local } from "./canvasIndex"; @@ -20,6 +22,22 @@ import { injectAgent } from "./inspectorAgent"; export const CANVAS_FILE_SHAPE_TYPE = "canvas-file" as const; +/** + * Outlines the element under the canvas's pointer, which installBoardHover (canvasClicks.ts) + * posts in board px as `sp:at`; a point off the board is (-1, -1) and clears it. A path is its + * icon's, so an svg outlines whole. ES5, since it runs in whatever the board is. + */ +const HOVER = + "