|
| 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
| 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 | +} |
0 commit comments