Skip to content

Commit d1afa86

Browse files
authored
Merge pull request #66 from ReScienceLab/canvas/board-web-pages
canvas: read a board, or a whole page of them, as a web page
2 parents 6c3289f + 519963e commit d1afa86

7 files changed

Lines changed: 296 additions & 5 deletions

File tree

‎canvas/src/InspectorPanel.tsx‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
BOARD_STATUS_LABEL,
99
LAYOUT_CHANGED,
1010
type CanvasBoardStatus,
11+
boardPageUrl,
1112
boardStatusForPath,
1213
readCanvasAssetNames,
1314
useCanvasFileHtml,
@@ -316,6 +317,9 @@ export function InspectorPanel({
316317
}) {
317318
const html = useCanvasFileHtml(path);
318319
const srcDoc = useMemo(() => (html ? injectAgent(html) : ""), [html]);
320+
// The board's own HTML, not `srcDoc`: the agent injected there talks to a parent frame that
321+
// a tab of its own does not have.
322+
const pageUrl = html ? boardPageUrl(path, html) : "";
319323
const frame = useRef<HTMLIFrameElement>(null);
320324
const [data, setData] = useState<SpReady | null>(null);
321325
const [sel, setSel] = useState<number | null>(null);
@@ -509,7 +513,35 @@ export function InspectorPanel({
509513
) : null}
510514
</div>
511515
<BoardStatus path={path} />
512-
<span className="sp-zoom">{Math.round(scale * 100)}%</span>
516+
{/*
517+
The board at its own size, in a tab of its own. The preview is scaled to fit the
518+
stage, so it is the wrong place to read type or tap through a flow; this is the
519+
same board as an ordinary web page. An anchor, not a button, so the ordinary ways
520+
to open a link — middle click, ⌘-click, copy — all work on it.
521+
*/}
522+
{pageUrl ? (
523+
<a
524+
className="sp-full"
525+
href={pageUrl}
526+
target="_blank"
527+
rel="noopener noreferrer"
528+
title="Open this board as a page"
529+
aria-label="Open this board as a page"
530+
>
531+
<svg
532+
width="12"
533+
height="12"
534+
viewBox="0 0 12 12"
535+
fill="none"
536+
stroke="currentColor"
537+
strokeWidth="1.4"
538+
strokeLinecap="round"
539+
strokeLinejoin="round"
540+
>
541+
<path d="M4.5 1.5h-3v3M7.5 1.5h3v3M10.5 7.5v3h-3M1.5 7.5v3h3" />
542+
</svg>
543+
</a>
544+
) : null}
513545
</div>
514546
{editor ? (
515547
<BoardComments

‎canvas/src/boardsPage.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { boardsPageDoc } from './boardsPage'
3+
import { WELCOME_PAGE_SLUG } from './canvasUrl'
4+
5+
/** The document is built as a string, so the assertions read it as one. */
6+
const captions = (doc: string) => [...doc.matchAll(/<figcaption>(.*?)<\/figcaption>/g)].map((m) => m[1])
7+
const headings = (doc: string) => [...doc.matchAll(/<h2>(.*?)<\/h2>/g)].map((m) => m[1])
8+
9+
describe('boardsPageDoc', () => {
10+
it('lays the boards out in layout.json order, with the canvas own captions', async () => {
11+
const doc = await boardsPageDoc('notion-ios')
12+
expect(headings(doc)).toEqual([
13+
'Foundations',
14+
'Notion iOS replica screens',
15+
'Flow: adding a new data source',
16+
'Flow: adding an account',
17+
'Flow: the purchase sheet',
18+
])
19+
// A `numbered` row counts from 1 within that row; a plain one is the file's own title.
20+
expect(captions(doc).slice(0, 3)).toEqual(['Design tokens', '1 · Splash', '2 · Search / Ask AI'])
21+
expect(doc).toContain('<h1>(example) Notion iOS</h1>')
22+
23+
// Every board is an iframe at the artboard size, pointed at that board as a page of its own.
24+
const frames = [...doc.matchAll(/<iframe src="(blob:[^"]+)" width="(\d+)" height="(\d+)"/g)]
25+
expect(frames).toHaveLength(captions(doc).length)
26+
expect(frames.every(([, , w, h]) => w === '478' && h === '980')).toBe(true)
27+
expect(new Set(frames.map(([, src]) => src)).size).toBe(frames.length)
28+
})
29+
30+
it('gives a board the size its entry declares', async () => {
31+
// The welcome strip is the one board in the repo that is not phone-shaped.
32+
const doc = await boardsPageDoc(WELCOME_PAGE_SLUG)
33+
expect(doc).toContain('width="2153" height="819"')
34+
expect(captions(doc)).toEqual(['What this is'])
35+
expect(doc).toContain('<p class="sub">1 board at full size</p>')
36+
})
37+
38+
it('has nothing to draw for a page with no boards', async () => {
39+
const doc = await boardsPageDoc('not-a-folder')
40+
expect(doc).not.toContain('<iframe')
41+
expect(doc).toContain('<p class="sub">0 boards at full size</p>')
42+
})
43+
})

‎canvas/src/boardsPage.ts‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { CANVAS_FILE_DEFAULT_SIZE } from "./CanvasFileShapeUtil";
2+
import {
3+
type CanvasLibraryFile,
4+
boardPageUrl,
5+
loadCanvasFileHtml,
6+
pageNameFor,
7+
readCanvasLayout,
8+
readCanvasLibrary,
9+
} from "./canvasLibrary";
10+
11+
/**
12+
* A page's boards as one scrolling web page, for the button in the top bar.
13+
*
14+
* The canvas is the place to read a flow; this is the place to read a screen. Every board is an
15+
* iframe at its own size, so type is at the size it will ship at and anything the board does on
16+
* a tap still does it — which a canvas at 25% cannot show and the inspector can only show one
17+
* board at a time.
18+
*/
19+
20+
/** A board on the sheet: where its page is, what to call it, and how big it is. */
21+
interface SheetBoard {
22+
src: string;
23+
caption: string;
24+
w: number;
25+
h: number;
26+
}
27+
28+
interface SheetRow {
29+
title: string;
30+
boards: SheetBoard[];
31+
}
32+
33+
/** For text going into the document below, which is built as a string. */
34+
const esc = (text: string) =>
35+
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
36+
37+
/**
38+
* The page's boards in the order the canvas lays them out: layout.json's rows, with their titles
39+
* and captions, then whatever no row claimed, the way the canvas puts the leftovers in a grid
40+
* below rather than dropping them. Read from layout.json rather than off the shapes on screen,
41+
* because a caption out there is a text shape and its rich text is a worse source for a string
42+
* than the file the string was made from.
43+
*/
44+
function sheetRows(slug: string, files: CanvasLibraryFile[], src: Map<string, string>): SheetRow[] {
45+
const rows: SheetRow[] = [];
46+
const placed = new Set<string>();
47+
48+
for (const row of readCanvasLayout(slug)?.rows ?? []) {
49+
const boards: SheetBoard[] = [];
50+
for (const entry of row.files) {
51+
// An entry is the file name alone, or that name with the overrides beside it.
52+
const declared = typeof entry === "string" ? { file: entry } : entry;
53+
const file = files.find((c) => c.fileName === declared.file && !placed.has(c.path));
54+
const url = file && src.get(file.path);
55+
if (!file || !url) continue;
56+
placed.add(file.path);
57+
const caption = declared.label ?? file.title;
58+
boards.push({
59+
src: url,
60+
caption: row.numbered ? `${boards.length + 1} · ${caption}` : caption,
61+
// Both or neither, the way the canvas reads a size override.
62+
...(declared.w && declared.h ? { w: declared.w, h: declared.h } : CANVAS_FILE_DEFAULT_SIZE),
63+
});
64+
}
65+
if (boards.length) rows.push({ title: row.title, boards });
66+
}
67+
68+
const leftover = files.filter((file) => !placed.has(file.path) && src.has(file.path));
69+
if (leftover.length) {
70+
rows.push({
71+
title: rows.length ? "Everything else" : "",
72+
boards: leftover.map((file) => ({
73+
src: src.get(file.path)!,
74+
caption: file.title,
75+
...CANVAS_FILE_DEFAULT_SIZE,
76+
})),
77+
});
78+
}
79+
return rows;
80+
}
81+
82+
/* The sheet's own chrome. Grey ground and grey captions, like the inspector's stage, so that the
83+
only thing on the page with any colour in it is the mockups. */
84+
const SHEET_CSS = `
85+
:root { color-scheme: light }
86+
* { box-sizing: border-box }
87+
body {
88+
margin: 0;
89+
padding: 32px 40px 72px;
90+
background: #f5f5f5;
91+
color: #171717;
92+
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
93+
}
94+
h1 { margin: 0; font-size: 22px; letter-spacing: -0.01em }
95+
.sub { margin: 4px 0 0; color: #767676 }
96+
h2 { margin: 40px 0 16px; font-size: 15px; font-weight: 600 }
97+
.row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 28px 24px }
98+
figure { margin: 0; display: flex; flex-direction: column; gap: 8px }
99+
iframe { display: block; border: 0; border-radius: 12px; background: #ffffff; box-shadow: 0 1px 3px #00000014 }
100+
figcaption { color: #767676; font-size: 13px }
101+
`;
102+
103+
/** Every board on a canvas page, as one HTML document. */
104+
export async function boardsPageDoc(slug: string) {
105+
const name = pageNameFor(slug);
106+
const files = readCanvasLibrary().find((page) => page[0].pageSlug === slug) ?? [];
107+
const loaded = await Promise.all(
108+
files.map(async (file) => [file.path, await loadCanvasFileHtml(file.path)] as const),
109+
);
110+
const src = new Map(
111+
// A board whose chunk never arrived is left off the sheet; it is blank on the canvas too.
112+
loaded.flatMap(([path, html]) => (html ? [[path, boardPageUrl(path, html)] as const] : [])),
113+
);
114+
const rows = sheetRows(slug, files, src);
115+
const count = rows.reduce((n, row) => n + row.boards.length, 0);
116+
117+
return `<!doctype html>
118+
<html lang="en">
119+
<head>
120+
<meta charset="utf-8">
121+
<meta name="viewport" content="width=device-width, initial-scale=1">
122+
<title>${esc(name)}</title>
123+
<style>${SHEET_CSS}</style>
124+
</head>
125+
<body>
126+
<h1>${esc(name)}</h1>
127+
<p class="sub">${count} board${count === 1 ? "" : "s"} at full size</p>
128+
${rows
129+
.map(
130+
(row) => `<section>
131+
${row.title ? `<h2>${esc(row.title)}</h2>` : ""}
132+
<div class="row">
133+
${row.boards
134+
.map(
135+
(board) => `<figure>
136+
<iframe src="${board.src}" width="${board.w}" height="${board.h}" loading="lazy" title="${esc(board.caption)}"></iframe>
137+
<figcaption>${esc(board.caption)}</figcaption>
138+
</figure>`,
139+
)
140+
.join("\n")}
141+
</div>
142+
</section>`,
143+
)
144+
.join("\n")}
145+
</body>
146+
</html>`;
147+
}
148+
149+
/**
150+
* Opens that document in a tab of its own.
151+
*
152+
* The tab is opened before the boards are fetched and pointed at the page afterwards: a
153+
* `window.open` on the far side of an await is a popup the browser blocks, because the click that
154+
* allowed it is over by then. Most of the time there is nothing to wait for anyway — every shape
155+
* on a page mounts, so a page's boards are in the library's cache before anyone can click.
156+
*/
157+
export async function openBoardsPage(slug: string) {
158+
const tab = window.open("", "_blank");
159+
// Blocked by the browser, and there is no window left to say so in.
160+
if (!tab) return;
161+
tab.document.title = pageNameFor(slug);
162+
const doc = await boardsPageDoc(slug);
163+
tab.location.replace(URL.createObjectURL(new Blob([doc], { type: "text/html" })));
164+
}

‎canvas/src/canvasChrome.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
type CommentUser,
2929
} from "./canvasComments";
3030
import type { CanvasFileShape } from "./CanvasFileShapeUtil";
31+
import { openBoardsPage } from "./boardsPage";
3132
import { WELCOME_PAGE_SLUG } from "./canvasUrl";
3233

3334
const REPO_URL = "https://github.com/ReScienceLab/super-prototyping";
@@ -164,6 +165,15 @@ export const canvasChromeComponents: TLComponents = {
164165
return (
165166
<>
166167
<DefaultActionsMenu {...props} />
168+
{slug && (
169+
<TldrawUiButton
170+
type="icon"
171+
title="Open every board on this page as one web page"
172+
onClick={() => openBoardsPage(slug)}
173+
>
174+
<TldrawUiButtonIcon icon="external-link" />
175+
</TldrawUiButton>
176+
)}
167177
{/* Nothing to copy on the welcome page, which the app draws and no folder backs, or on
168178
a page someone added by hand. */}
169179
{import.meta.env.DEV && slug && slug !== WELCOME_PAGE_SLUG && (

‎canvas/src/canvasLibrary.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,27 @@ export function loadCanvasFileHtml(path: string): Promise<string | undefined> {
268268
return load;
269269
}
270270

271+
/** path -> the blob URL that board has been opened as a page at; see `boardPageUrl`. */
272+
const boardPages = new Map<string, string>();
273+
274+
/**
275+
* A board as a page of its own, for the buttons that open one. A blob rather than a link to the
276+
* file because boards ship as strings in the bundle — lazy chunks off `virtual:canvases` — so
277+
* there is no address to point at.
278+
*
279+
* One per board, kept for the session rather than revoked when the thing that asked for it goes
280+
* away: nothing here can see when the tab it was opened in has finished fetching it, and revoking
281+
* before that leaves the reader on an error page. It is a second copy of a string `canvasFileHtml`
282+
* is already holding for as long anyway.
283+
*/
284+
export function boardPageUrl(path: string, html: string) {
285+
const made = boardPages.get(path);
286+
if (made) return made;
287+
const url = URL.createObjectURL(new Blob([html], { type: "text/html" }));
288+
boardPages.set(path, url);
289+
return url;
290+
}
291+
271292
/**
272293
* A rendering shape's board HTML: undefined until its chunk arrives, then the string. Every shape
273294
* on the page mounts (culling only hides the off-screen ones), so a page fetches its own boards

‎canvas/src/index.css‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -463,12 +463,27 @@ input.canvas-clone-name:focus {
463463
box-shadow: 0 0 0 2px #ffffff, 0 1px 4px #00000040;
464464
}
465465

466-
.sp-zoom {
466+
/* Opens the board as a page of its own. Sits where the zoom percentage used to: the corner of
467+
the stage is the one part of it no board reaches, and the scale was a number nobody acted on. */
468+
.sp-full {
467469
position: absolute;
468-
right: 12px;
469-
bottom: 10px;
470+
right: 8px;
471+
bottom: 8px;
472+
width: 24px;
473+
height: 24px;
474+
display: flex;
475+
align-items: center;
476+
justify-content: center;
477+
border-radius: 6px;
470478
color: var(--sp-text-2);
471-
pointer-events: none;
479+
background: #ffffffd9;
480+
box-shadow: 0 1px 3px #00000014;
481+
cursor: pointer;
482+
}
483+
484+
.sp-full:hover {
485+
color: var(--sp-text);
486+
background: #ffffff;
472487
}
473488

474489
/* The rail's collapse handle, a lozenge straddling its divider. It was a 26px chip in the top

‎skills/prototype-canvas/SKILL.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ actually bind, and prints the address.
4646
`#<file>` after that, e.g. `?canvas=notion-ios#02-search-ask-ai`: it opens
4747
in the inspector with the camera on it. Give the board link when pointing
4848
at one screen.
49+
- **Read a board as a web page.** The external-link button in the top bar
50+
opens every board of the page in one scrolling document, each at its own
51+
size; the button in the bottom right of the inspector's preview opens the
52+
one board it is showing. Both are the board's own HTML in a tab of its own,
53+
which is where to read type at the size it ships at, rather than at whatever
54+
the canvas is zoomed to.
4955

5056
Keep it on loopback. This is a local design tool, not a service to expose.
5157

0 commit comments

Comments
 (0)