Skip to content

Commit 717d7fb

Browse files
authored
Merge pull request #195 from ReScienceLab/fullscreen-zoom
Inspector behind a switch; double-click zooms to fill; Space presents a board
2 parents fa11600 + af9a54b commit 717d7fb

16 files changed

Lines changed: 640 additions & 489 deletions

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ them all. Each is a real `sp-clone-prototype` run, rebuilt
2929
from measured samples with the evidence recorded for every token. Open any
3030
of them with `?canvas=<slug>`, and one board of it with
3131
`?canvas=<slug>#<file>`. The address follows whatever is open, the page and
32-
the board in the inspector, so the URL in the bar is always the link to share.
32+
the selected board, so the URL in the bar is always the link to share.
3333

3434
### `duolingo-ios`, eight screens that are mostly picture
3535

@@ -163,8 +163,9 @@ they say. `sp paths` lists the two directories it writes, and
163163
`sp clean` removes them.
164164

165165
Deep-link a page with `?canvas=<slug>`, and one board of it with
166-
`?canvas=<slug>#<file>`: it opens in the inspector with the camera on it, and
167-
clicking any board writes that link into the address bar. Right-clicking the
166+
`?canvas=<slug>#<file>`: it opens selected, zoomed to fill the window, and
167+
clicking any board writes that link into the address bar. Double-click a
168+
board or an image to zoom it to fill the window. Right-clicking the
168169
canvas offers Force refresh; choose it after editing a `layout.json`. A board
169170
folder added after boot appears on its own.
170171

‎canvas/src/App.tsx‎

Lines changed: 150 additions & 252 deletions
Large diffs are not rendered by default.

‎canvas/src/CanvasFileShapeUtil.tsx‎

Lines changed: 51 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
import { useContext, useMemo, type CSSProperties } from "react";
1+
import { useContext, type CSSProperties } from "react";
22
import {
33
BaseBoxShapeUtil,
44
FileHelpers,
55
HTMLContainer,
66
T,
77
type RecordProps,
88
type TLShape,
9+
useEditor,
910
useIsEditing,
11+
useValue,
1012
} from "tldraw";
1113
import { CanvasChromeContext } from "./canvasChrome";
1214
import { local } from "./canvasIndex";
@@ -20,6 +22,22 @@ import { injectAgent } from "./inspectorAgent";
2022

2123
export const CANVAS_FILE_SHAPE_TYPE = "canvas-file" as const;
2224

25+
/**
26+
* Outlines the element under the canvas's pointer, which installBoardHover (canvasClicks.ts)
27+
* posts in board px as `sp:at`; a point off the board is (-1, -1) and clears it. A path is its
28+
* icon's, so an svg outlines whole. ES5, since it runs in whatever the board is.
29+
*/
30+
const HOVER =
31+
"<script>(function(){var d=document.createElement('div');" +
32+
"d.style.cssText='position:fixed;pointer-events:none;z-index:2147483647;display:none;box-sizing:border-box;box-shadow:0 0 0 1px #006EFE';" +
33+
"document.documentElement.appendChild(d);" +
34+
"addEventListener('message',function(e){var m=e.data;if(!m||m.type!=='sp:at')return;" +
35+
"var el=document.elementFromPoint(m.x,m.y);if(el&&el.closest&&el.closest('svg'))el=el.closest('svg');" +
36+
"if(!el||el===document.body||el===document.documentElement){d.style.display='none';return;}" +
37+
"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';});" +
38+
"})();</" +
39+
"script>";
40+
2341
declare module "tldraw" {
2442
export interface TLGlobalShapePropsMap {
2543
[CANVAS_FILE_SHAPE_TYPE]: {
@@ -36,28 +54,19 @@ export type CanvasFileShape = TLShape<typeof CANVAS_FILE_SHAPE_TYPE>;
3654
// oxlint-disable-next-line react/only-export-components
3755
function CanvasFile({ shape }: { shape: CanvasFileShape }) {
3856
const isEditing = useIsEditing(shape.id);
39-
const { inspectingPath, setInspectorFrame } = useContext(CanvasChromeContext);
4057
const html = useCanvasFileHtml(shape.props.path);
41-
42-
/**
43-
* The board the inspector has open runs the agent (inspectorAgent.ts), so a click on the mockup
44-
* out here picks the element under it. The panel used to load a second copy of the board to do
45-
* that, which meant reading one mockup and clicking another.
46-
*
47-
* The frame still never takes the pointer: inspectorClicks.ts hands the agent the canvas's own
48-
* pointer as a board coordinate, so panning, zooming and the comment tool go on working over
49-
* the board being read.
50-
*/
51-
const inspected = inspectingPath === shape.props.path;
52-
const agentDoc = useMemo(
53-
() => (html && inspected ? injectAgent(html) : null),
54-
[html, inspected],
58+
const { inspectorOn, setInspectorFrame } = useContext(CanvasChromeContext);
59+
const editor = useEditor();
60+
const selected = useValue(
61+
"board selected",
62+
() => editor.getOnlySelectedShapeId() === shape.id,
63+
[editor, shape.id],
5564
);
5665

57-
// Behind the container, which is transparent, so the frames show through it. Safari routes a
66+
// Behind the container, which is transparent, so the frame shows through it. Safari routes a
5867
// wheel to an iframe's own scrolling area whatever pointer-events says, so a two-finger pan
5968
// over a board did nothing there, and a horizontal one chained out to the browser's back
60-
// gesture. Behind the container neither frame is a scroll target, and the pan reaches tldraw
69+
// gesture. Behind the container the frame is no scroll target, and the pan reaches tldraw
6170
// wherever the cursor is. tldraw's own embed shape carries this same line:
6271
// <https://stackoverflow.com/a/49150908>.
6372
const frame: CSSProperties = {
@@ -94,31 +103,41 @@ function CanvasFile({ shape }: { shape: CanvasFileShape }) {
94103
sandbox=""
95104
style={{ ...frame, zIndex: isEditing ? undefined : -2 }}
96105
/>
97-
{/* The scripted board is a second document: srcdoc cannot be swapped on the frame above
98-
(Chrome drops the second navigation while the first is still pending and leaves the
99-
frame blank), and remounting it reloaded the mockup under the very click that opened
100-
it, which is the flash. It loads over the board instead, pixel for pixel the same
101-
one, so the swap is invisible — and the board underneath stays loaded, so closing
102-
the inspector shows nothing either. */}
103-
{agentDoc ? (
106+
{/* The selected board outlines the element under the pointer: with the inspector on it
107+
runs the inspect agent (inspectorAgent.ts), which also picks the element clicked and
108+
reports the board to the panel, and otherwise only HOVER. It loads over the board
109+
rather than into its frame: Chrome drops a second srcdoc navigation while the first
110+
is pending and leaves the frame blank, and a remount reloads the mockup under the
111+
click that selected it. Pixel for pixel the same board, so the swap is invisible.
112+
`allow-scripts` and deliberately not `allow-same-origin`, which together would let
113+
the board reach back out into the canvas. */}
114+
{selected && inspectorOn ? (
104115
<iframe
105116
ref={(el) => {
106117
setInspectorFrame(el);
107118
return () => setInspectorFrame(null);
108119
}}
109120
title={shape.props.name}
110-
srcDoc={agentDoc}
111-
// `allow-scripts` and deliberately not `allow-same-origin`, which together would let
112-
// the frame reach back out into the canvas.
121+
srcDoc={injectAgent(html)}
113122
sandbox="allow-scripts"
123+
data-sp-hover={shape.id}
114124
// The agent answers with its report; the frame's own load event may have fired
115125
// before the panel was listening.
116126
onLoad={(e) =>
117-
e.currentTarget.contentWindow?.postMessage(
118-
{ type: "sp:hello" },
119-
"*",
120-
)
127+
e.currentTarget.contentWindow?.postMessage({ type: "sp:hello" }, "*")
128+
}
129+
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
130+
/>
131+
) : selected ? (
132+
<iframe
133+
title={shape.props.name}
134+
srcDoc={
135+
/<\/body>/i.test(html)
136+
? html.replace(/<\/body>/i, (tag) => HOVER + tag)
137+
: html + HOVER
121138
}
139+
sandbox="allow-scripts"
140+
data-sp-hover={shape.id}
122141
style={{ ...frame, zIndex: isEditing ? undefined : -1 }}
123142
/>
124143
) : null}

‎canvas/src/CanvasPresent.tsx‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { type ReactNode, useEffect, useState } from "react";
2+
import { createPortal } from "react-dom";
3+
import type { Editor } from "tldraw";
4+
import { asCanvasTarget, type CanvasTarget } from "./canvasClicks";
5+
import { CANVAS_FILE_SHAPE_TYPE } from "./CanvasFileShapeUtil";
6+
import { useCanvasFileHtml } from "./canvasLibrary";
7+
8+
/** Screen px between a presented board and the window's edge. */
9+
const INSET = 32;
10+
11+
/**
12+
* Space on a selected board or picture shows it alone over the whole window (`Present`), scaled
13+
* to fit it. Taken from tldraw on the key going down, so with one of them selected Space is this
14+
* and not the hand.
15+
*/
16+
export function CanvasPresent({ editor }: { editor: Editor | null }) {
17+
const [shown, setShown] = useState<CanvasTarget>();
18+
const top = window.top!;
19+
const [view, setView] = useState({ w: top.innerWidth, h: top.innerHeight });
20+
21+
useEffect(() => {
22+
if (!editor || shown) return;
23+
const onDown = (e: KeyboardEvent) => {
24+
if (e.code !== "Space" || e.repeat) return;
25+
// Space on a focused control is that control's: typing, or pressing a button.
26+
const control =
27+
e.target instanceof HTMLElement &&
28+
(e.target.isContentEditable ||
29+
e.target.closest(
30+
"input, textarea, select, button, a[href], [role=button], [role=menuitem], [role=tab]",
31+
));
32+
if (control || editor.getEditingShapeId()) return;
33+
const selected = editor.getSelectedShapes();
34+
const target = selected.length === 1 && asCanvasTarget(selected[0]);
35+
if (!target) return;
36+
e.preventDefault();
37+
e.stopPropagation();
38+
setShown(target);
39+
};
40+
addEventListener("keydown", onDown, true);
41+
return () => removeEventListener("keydown", onDown, true);
42+
}, [editor, shown]);
43+
44+
useEffect(() => {
45+
const resized = () => setView({ w: top.innerWidth, h: top.innerHeight });
46+
top.addEventListener("resize", resized);
47+
return () => top.removeEventListener("resize", resized);
48+
}, [top]);
49+
50+
if (!editor || !shown) return null;
51+
return (
52+
<Present
53+
close={() => {
54+
setShown(undefined);
55+
editor.focus();
56+
}}
57+
>
58+
<Presented editor={editor} shape={shown} view={view} />
59+
</Present>
60+
);
61+
}
62+
63+
/**
64+
* Something shown alone over the whole window, the window dimmed round it: a board or picture
65+
* from the canvas, or a picture in the chat. Space, Esc or a click anywhere comes back to where
66+
* it was. Inside the window rather than the browser's full screen, which on a Mac moves the window
67+
* to a display of its own and back, slowly.
68+
*
69+
* In the window's document, not the canvas frame's, so it covers the tab bar and the chat as well
70+
* (the window loads the same stylesheet, shell.tsx). The keys are heard in both documents: the
71+
* focus can be in either.
72+
*/
73+
export function Present({
74+
close,
75+
children,
76+
}: {
77+
close: () => void;
78+
children: ReactNode;
79+
}) {
80+
const top = window.top!;
81+
useEffect(() => {
82+
const onDown = (e: KeyboardEvent) => {
83+
if (e.code !== "Space" && e.key !== "Escape") return;
84+
e.preventDefault();
85+
e.stopPropagation();
86+
if (!e.repeat) close();
87+
};
88+
const windows = top === window ? [window] : [window, top];
89+
for (const w of windows) w.addEventListener("keydown", onDown, true);
90+
return () => {
91+
for (const w of windows) w.removeEventListener("keydown", onDown, true);
92+
};
93+
}, [close, top]);
94+
return createPortal(
95+
<div
96+
className="sp-present"
97+
onClick={close}
98+
>
99+
{children}
100+
</div>,
101+
top.document.body,
102+
);
103+
}
104+
105+
function Presented({
106+
editor,
107+
shape,
108+
view,
109+
}: {
110+
editor: Editor;
111+
shape: CanvasTarget;
112+
view: { w: number; h: number };
113+
}) {
114+
const board = shape.type === CANVAS_FILE_SHAPE_TYPE ? shape : undefined;
115+
const html = useCanvasFileHtml(board?.props.path ?? "");
116+
const { w, h } = shape.props;
117+
if (board)
118+
return (
119+
<iframe
120+
title={board.props.name}
121+
srcDoc={html}
122+
sandbox=""
123+
style={{
124+
width: w,
125+
height: h,
126+
scale: String(
127+
Math.min((view.w - 2 * INSET) / w, (view.h - 2 * INSET) / h),
128+
),
129+
}}
130+
/>
131+
);
132+
const src =
133+
shape.type === "image" && shape.props.assetId
134+
? editor.getAsset(shape.props.assetId)?.props.src
135+
: undefined;
136+
return src ? <img alt="" src={src} /> : null;
137+
}

‎canvas/src/CanvasStrip.tsx‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
TRASH,
1616
} from "./contextMenu";
1717
import { DocModeSwitch } from "./DocTab";
18-
import { FileText, LogoFigma, Plus } from "./geistIcons";
18+
import { FileText, Inspect, LogoFigma, Plus } from "./geistIcons";
1919

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

3738
export function CanvasStrip() {
38-
const { activeTab, openTab, editor } = useContext(CanvasChromeContext);
39+
const { activeTab, openTab, editor, inspectorOn, setInspectorOn } =
40+
useContext(CanvasChromeContext);
3941
const tab = tabFor(activeTab);
4042
const canvases = tab.kind === "example" ? [tab.slug] : ownCanvases();
4143
const here = activeTab.kind === "canvas" ? activeTab.slug : undefined;
@@ -213,6 +215,18 @@ export function CanvasStrip() {
213215
/>
214216
</label>
215217
)}
218+
{page && (
219+
<button
220+
type="button"
221+
className="sp-canvas-tabs-inspector"
222+
aria-pressed={inspectorOn}
223+
aria-label="Inspector"
224+
title={inspectorOn ? "Hide the inspector" : "Inspect the selected board"}
225+
onClick={() => setInspectorOn(!inspectorOn)}
226+
>
227+
<Inspect />
228+
</button>
229+
)}
216230
{/* An anchor, not a button, because the sheet is a page of its own, and the page that
217231
walks through the import, so ⌘-click and copy-link have to work on it. */}
218232
{slug && (

0 commit comments

Comments
 (0)