Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<slug>`, and one board of it with
`?canvas=<slug>#<file>`. 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

Expand Down Expand Up @@ -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=<slug>`, and one board of it with
`?canvas=<slug>#<file>`: 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=<slug>#<file>`: 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.

Expand Down
402 changes: 150 additions & 252 deletions canvas/src/App.tsx

Large diffs are not rendered by default.

83 changes: 51 additions & 32 deletions canvas/src/CanvasFileShapeUtil.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useContext, useMemo, type CSSProperties } from "react";
import { useContext, type CSSProperties } from "react";
import {
BaseBoxShapeUtil,
FileHelpers,
HTMLContainer,
T,
type RecordProps,
type TLShape,
useEditor,
useIsEditing,
useValue,
} from "tldraw";
import { CanvasChromeContext } from "./canvasChrome";
import { local } from "./canvasIndex";
Expand All @@ -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 =
"<script>(function(){var d=document.createElement('div');" +
"d.style.cssText='position:fixed;pointer-events:none;z-index:2147483647;display:none;box-sizing:border-box;box-shadow:0 0 0 1px #006EFE';" +
"document.documentElement.appendChild(d);" +
"addEventListener('message',function(e){var m=e.data;if(!m||m.type!=='sp:at')return;" +
"var el=document.elementFromPoint(m.x,m.y);if(el&&el.closest&&el.closest('svg'))el=el.closest('svg');" +
"if(!el||el===document.body||el===document.documentElement){d.style.display='none';return;}" +
"var r=el.getBoundingClientRect(),s=d.style;s.display='block';s.left=r.left+'px';s.top=r.top+'px';s.width=r.width+'px';s.height=r.height+'px';});" +
"})();</" +
"script>";

declare module "tldraw" {
export interface TLGlobalShapePropsMap {
[CANVAS_FILE_SHAPE_TYPE]: {
Expand All @@ -36,28 +54,19 @@ export type CanvasFileShape = TLShape<typeof CANVAS_FILE_SHAPE_TYPE>;
// 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],
const { inspectorOn, setInspectorFrame } = useContext(CanvasChromeContext);
const editor = useEditor();
const selected = useValue(
"board selected",
() => editor.getOnlySelectedShapeId() === shape.id,
[editor, shape.id],
);

// 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:
// <https://stackoverflow.com/a/49150908>.
const frame: CSSProperties = {
Expand Down Expand Up @@ -94,31 +103,41 @@ function CanvasFile({ shape }: { shape: CanvasFileShape }) {
sandbox=""
style={{ ...frame, zIndex: isEditing ? undefined : -2 }}
/>
{/* 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 ? (
{/* The selected board outlines the element under the pointer: with the inspector on it
runs the inspect agent (inspectorAgent.ts), which also picks the element clicked and
reports the board to the panel, and otherwise only HOVER. It loads over the board
rather than into its frame: Chrome drops a second srcdoc navigation while the first
is pending and leaves the frame blank, and a remount reloads the mockup under the
click that selected it. Pixel for pixel the same board, so the swap is invisible.
`allow-scripts` and deliberately not `allow-same-origin`, which together would let
the board reach back out into the canvas. */}
{selected && inspectorOn ? (
<iframe
ref={(el) => {
setInspectorFrame(el);
return () => setInspectorFrame(null);
}}
title={shape.props.name}
srcDoc={agentDoc}
// `allow-scripts` and deliberately not `allow-same-origin`, which together would let
// the frame reach back out into the canvas.
srcDoc={injectAgent(html)}
sandbox="allow-scripts"
data-sp-hover={shape.id}
// The agent answers with its report; the frame's own load event may have fired
// before the panel was listening.
onLoad={(e) =>
e.currentTarget.contentWindow?.postMessage(
{ type: "sp:hello" },
"*",
)
e.currentTarget.contentWindow?.postMessage({ type: "sp:hello" }, "*")
}
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
/>
) : selected ? (
<iframe
title={shape.props.name}
srcDoc={
/<\/body>/i.test(html)
? html.replace(/<\/body>/i, (tag) => HOVER + tag)
: html + HOVER
}
sandbox="allow-scripts"
data-sp-hover={shape.id}
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
/>
) : null}
Expand Down
137 changes: 137 additions & 0 deletions canvas/src/CanvasPresent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { type ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "tldraw";
import { asCanvasTarget, type CanvasTarget } from "./canvasClicks";
import { CANVAS_FILE_SHAPE_TYPE } from "./CanvasFileShapeUtil";
import { useCanvasFileHtml } from "./canvasLibrary";

/** Screen px between a presented board and the window's edge. */
const INSET = 32;

/**
* Space on a selected board or picture shows it alone over the whole window (`Present`), scaled
* to fit it. Taken from tldraw on the key going down, so with one of them selected Space is this
* and not the hand.
*/
export function CanvasPresent({ editor }: { editor: Editor | null }) {
const [shown, setShown] = useState<CanvasTarget>();
const top = window.top!;
const [view, setView] = useState({ w: top.innerWidth, h: top.innerHeight });

useEffect(() => {
if (!editor || shown) return;
const onDown = (e: KeyboardEvent) => {
if (e.code !== "Space" || e.repeat) return;
// Space on a focused control is that control's: typing, or pressing a button.
const control =
e.target instanceof HTMLElement &&
(e.target.isContentEditable ||
e.target.closest(
"input, textarea, select, button, a[href], [role=button], [role=menuitem], [role=tab]",
));
if (control || editor.getEditingShapeId()) return;
const selected = editor.getSelectedShapes();
const target = selected.length === 1 && asCanvasTarget(selected[0]);
if (!target) return;
e.preventDefault();
e.stopPropagation();
setShown(target);
};
addEventListener("keydown", onDown, true);
return () => removeEventListener("keydown", onDown, true);
}, [editor, shown]);

useEffect(() => {
const resized = () => setView({ w: top.innerWidth, h: top.innerHeight });
top.addEventListener("resize", resized);
return () => top.removeEventListener("resize", resized);
}, [top]);

if (!editor || !shown) return null;
return (
<Present
close={() => {
setShown(undefined);
editor.focus();
}}
>
<Presented editor={editor} shape={shown} view={view} />
</Present>
);
}

/**
* Something shown alone over the whole window, the window dimmed round it: a board or picture
* from the canvas, or a picture in the chat. Space, Esc or a click anywhere comes back to where
* it was. Inside the window rather than the browser's full screen, which on a Mac moves the window
* to a display of its own and back, slowly.
*
* In the window's document, not the canvas frame's, so it covers the tab bar and the chat as well
* (the window loads the same stylesheet, shell.tsx). The keys are heard in both documents: the
* focus can be in either.
*/
export function Present({
close,
children,
}: {
close: () => void;
children: ReactNode;
}) {
const top = window.top!;
useEffect(() => {
const onDown = (e: KeyboardEvent) => {
if (e.code !== "Space" && e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
if (!e.repeat) close();
};
const windows = top === window ? [window] : [window, top];
for (const w of windows) w.addEventListener("keydown", onDown, true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep presentation dismissible after focusing the board

When a presented board is clicked, focus moves into the nested srcDoc iframe, but keyboard events do not cross iframe boundaries, so neither listener installed here receives the subsequent Space or Escape. Because the overlay has no close button or click-to-dismiss handler, an interactive board can leave the user unable to exit presentation using the documented keys until they manually refocus the outer document; prevent the preview iframe from taking focus or provide a dismissal mechanism inside/over it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ffd038d: the presented frame takes no pointer, so it never gets the focus, and a click anywhere closes it. Checked in the browser: click on the board closes; Space then Esc still close.

return () => {
for (const w of windows) w.removeEventListener("keydown", onDown, true);
};
}, [close, top]);
return createPortal(
<div
className="sp-present"
onClick={close}
>
{children}
</div>,
top.document.body,
);
}

function Presented({
editor,
shape,
view,
}: {
editor: Editor;
shape: CanvasTarget;
view: { w: number; h: number };
}) {
const board = shape.type === CANVAS_FILE_SHAPE_TYPE ? shape : undefined;
const html = useCanvasFileHtml(board?.props.path ?? "");
const { w, h } = shape.props;
if (board)
return (
<iframe
title={board.props.name}
srcDoc={html}
sandbox=""
style={{
width: w,
height: h,
scale: String(
Math.min((view.w - 2 * INSET) / w, (view.h - 2 * INSET) / h),
),
}}
/>
);
const src =
shape.type === "image" && shape.props.assetId
? editor.getAsset(shape.props.assetId)?.props.src
: undefined;
return src ? <img alt="" src={src} /> : null;
Comment on lines +132 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Cropped pictures present their original image

When a library picture is cropped, Presented shows its full asset instead of the selected shape's crop. Space displays different artwork from the canvas.

Learn more

The canvas uses tldraw's image rendering and stores crop metadata in the selected shape's props.crop. The presentation instead renders the source asset URL directly in an <img>, discarding both crop coordinates and circular-crop information. An unlocked library picture can therefore appear cropped on the canvas but show its entire source when presented.

Example: Unlock a brand picture, crop it to its left half, and press Space with that picture selected. The presentation shows the entire source, including the hidden right half.

Recommended fix: Render the shape's crop and mask in Presented, using its shape dimensions and props.crop, or export the selected image shape at presentation resolution with tldraw's image renderer.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}
22 changes: 18 additions & 4 deletions canvas/src/CanvasStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
TRASH,
} from "./contextMenu";
import { DocModeSwitch } from "./DocTab";
import { FileText, LogoFigma, Plus } from "./geistIcons";
import { FileText, Inspect, LogoFigma, Plus } from "./geistIcons";

/**
* The project's canvases, across the top of the project under the bar's tab for it, after its
Expand All @@ -28,14 +28,16 @@ import { FileText, LogoFigma, Plus } from "./geistIcons";
* layout.json's, and the folder keeps its slug. An example is the app's, so it has no "+" and
* no renaming, and a build has no server to make a canvas.
*
* At the far end are the controls of the tab in front: a canvas's ground colour, then Export to
* Figma, the one place a canvas goes from here; a document's switch between reading and editing.
* At the far end are the controls of the tab in front: a canvas's ground colour, the inspector's
* switch, then Export to Figma, the one place a canvas goes from here; a document's switch
* between reading and editing.
*/
/** The canvas whose tab is up for renaming, kept across the reload that brings a new one in. */
const RENAME_KEY = "sp:rename-canvas";

export function CanvasStrip() {
const { activeTab, openTab, editor } = useContext(CanvasChromeContext);
const { activeTab, openTab, editor, inspectorOn, setInspectorOn } =
useContext(CanvasChromeContext);
const tab = tabFor(activeTab);
const canvases = tab.kind === "example" ? [tab.slug] : ownCanvases();
const here = activeTab.kind === "canvas" ? activeTab.slug : undefined;
Expand Down Expand Up @@ -213,6 +215,18 @@ export function CanvasStrip() {
/>
</label>
)}
{page && (
<button
type="button"
className="sp-canvas-tabs-inspector"
aria-pressed={inspectorOn}
aria-label="Inspector"
title={inspectorOn ? "Hide the inspector" : "Inspect the selected board"}
onClick={() => setInspectorOn(!inspectorOn)}
>
<Inspect />
</button>
Comment on lines +218 to +228

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Add an Unreleased entry for these controls

The new inspector switch, zoom gesture, and presentation mode have no entry under ## Unreleased. Contributing rules require one for user-visible changes in the same PR.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

)}
{/* An anchor, not a button, because the sheet is a page of its own, and the page that
walks through the import, so ⌘-click and copy-link have to work on it. */}
{slug && (
Expand Down
Loading
Loading