| ... | ... | @@ -4,9 +4,33 @@ |
| 4 | 4 | <meta charset="utf-8"> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content"> |
| 6 | 6 | <title>Snowbound</title> |
| 7 | <script> |
| 8 | // The open section's hue and the page's colour as the app last showed them (`keepLook`). |
| 9 | try { |
| 10 | const look = JSON.parse(localStorage.getItem("snowbound-look")); |
| 11 | const root = document.documentElement.style; |
| 12 | root.setProperty("--hue", look.hue); |
| 13 | if (look.dark === matchMedia("(prefers-color-scheme: dark)").matches) root.setProperty("--paper", look.paper); |
| 14 | } catch {} |
| 15 | </script> |
| 7 | 16 | <style> |
| 8 | | html, body { margin: 0; height: 100%; overflow: hidden; background: #f3f3f3; } |
| 9 | | @media (prefers-color-scheme: dark) { html, body { background: #202022; } } |
| 17 | /* The colours are ui::Theme's; a section's are Theme::section's for its hue, the first |
| 18 | new section's by default. */ |
| 19 | :root { |
| 20 | --hue: 220; --strip: #ebedf0; --base: #fcfcfd; --text: #1f2328; --dim: #7b828c; |
| 21 | --chip: #cfd3d9; --accent: #0079a6; --paper: #fff; |
| 22 | --frame: hsl(var(--hue) 55% 82%), hsl(calc(var(--hue) + 10) 55% 76%); |
| 23 | --edge: hsl(var(--hue) 30% 52%); |
| 24 | } |
| 25 | @media (prefers-color-scheme: dark) { |
| 26 | :root { |
| 27 | --strip: #272a2b; --base: #191b1c; --text: #dddee0; --dim: #6b7078; --chip: #383c3d; |
| 28 | --paper: #1f2022; |
| 29 | --frame: hsl(var(--hue) 30% 36%), hsl(calc(var(--hue) + 10) 30% 30%); |
| 30 | --edge: hsl(var(--hue) 30% 16%); |
| 31 | } |
| 32 | } |
| 33 | html, body { margin: 0; height: 100%; overflow: hidden; background: var(--strip); } |
| 10 | 34 | #page { display: block; width: 100vw; height: 100vh; height: 100dvh; touch-action: none; outline: none; } |
| 11 | 35 | /* The text area takes keys and input methods at the caret, unseen. */ |
| 12 | 36 | #input { |
| ... | ... | @@ -33,10 +57,50 @@ |
| 33 | 57 | dialog.ask p { margin: 0; white-space: pre-line; } |
| 34 | 58 | dialog.ask input { box-sizing: border-box; width: 100%; font: inherit; } |
| 35 | 59 | dialog.ask div { display: flex; flex-direction: row-reverse; gap: 8px; margin-top: 16px; } |
| 36 | | #status { |
| 37 | | position: fixed; inset: 0; display: grid; place-items: center; |
| 38 | | font: 15px system-ui, sans-serif; color: #666; pointer-events: none; |
| 60 | |
| 61 | /* The loading shell: the app's first frame, Full Page View's toolbar over the page (the |
| 62 | sizes are main.rs's and ui::shell's), every tool faded as one that doesn't apply. */ |
| 63 | #shell { |
| 64 | position: fixed; inset: 0; display: flex; flex-direction: column; background: var(--strip); |
| 65 | transition: opacity 120ms; user-select: none; cursor: default; |
| 66 | } |
| 67 | #shell.gone { opacity: 0; } |
| 68 | #tools { display: flex; gap: 6px; height: 34px; padding: 8px 6px 0; box-sizing: border-box; overflow: hidden; } |
| 69 | #tools span { display: flex; flex-shrink: 0; height: 22px; } |
| 70 | #tools .full, #tools .font { gap: 1px; } |
| 71 | #tools .folded, #tools .fold > .full { display: none; } |
| 72 | #tools .fold > .folded { display: flex; } |
| 73 | #tools .space { flex: 1 1 0; min-width: 0; } |
| 74 | #tools i { display: grid; place-items: center; width: 22px; height: 22px; color: var(--text); } |
| 75 | /* Centred however narrow the box, as the kit centres icons. */ |
| 76 | #tools i > svg { width: 16px; height: 16px; margin: 0 -8px; opacity: 0.35; } |
| 77 | #tools i.arrow { width: 10px; color: var(--dim); } |
| 78 | #tools i.more { width: 14px; color: var(--dim); } |
| 79 | #tools i.d { width: 6px; justify-items: start; } |
| 80 | #tools i.d::before { content: ""; width: 1px; height: 22px; background: var(--chip); } |
| 81 | #tools i.combo { |
| 82 | box-sizing: border-box; justify-items: end; padding-right: 13px; border-radius: 4px; |
| 83 | border: 1px solid color-mix(in srgb, var(--chip) 35%, transparent); background: var(--base); |
| 84 | } |
| 85 | #frame { |
| 86 | position: relative; flex: 1; background: linear-gradient(var(--frame)); |
| 87 | box-shadow: inset 1px 1px var(--edge), inset -1px 0 var(--edge); |
| 39 | 88 | } |
| 89 | /* The page's outline straddles its edge, a shade of the section's edge. */ |
| 90 | #paper { |
| 91 | position: absolute; inset: 6px; display: grid; place-content: center; justify-items: center; |
| 92 | gap: 10px; background: var(--paper); font: 13px system-ui, sans-serif; color: var(--dim); |
| 93 | --outline: color-mix(in srgb, color-mix(in srgb, var(--edge) 45%, black) 30%, transparent); |
| 94 | box-shadow: inset 0 0 0 1px var(--outline), 0 0 0 1px var(--outline); |
| 95 | } |
| 96 | #status { max-width: 360px; margin: 0; text-align: center; white-space: pre-line; } |
| 97 | #bar { width: 200px; height: 4px; border-radius: 2px; background: var(--chip); overflow: hidden; } |
| 98 | #bar::before { |
| 99 | content: ""; display: block; height: 100%; border-radius: inherit; background: var(--accent); |
| 100 | transform-origin: left; transform: scaleX(var(--done, 0)); transition: transform 100ms; |
| 101 | } |
| 102 | #shell.failed #bar { display: none; } |
| 103 | #shell.failed #status { color: var(--text); } |
| 40 | 104 | </style> |
| 41 | 105 | </head> |
| 42 | 106 | <body> |
| ... | ... | @@ -45,10 +109,47 @@ |
| 45 | 109 | <div id="a11y" class="hidden"></div> |
| 46 | 110 | <textarea id="input" autocapitalize="off" autocomplete="off" spellcheck="false" aria-label="Page"></textarea> |
| 47 | 111 | <input id="files" type="file" hidden> |
| 48 | | <div id="status">Loading Snowbound…</div> |
| 112 | <!-- release_web.py inlines each <i>'s icon; data-fold is a group's fold priority (main.rs). --> |
| 113 | <div id="shell"> |
| 114 | <div id="tools" aria-hidden="true"> |
| 115 | <span data-fold="6"><span class="full"><i icon="undo"></i><i icon="redo"></i></span><span class="folded"><span><i icon="undo"></i><i class="arrow" icon="chevron-down"></i></span></span></span> |
| 116 | <span><i class="d"></i><span><i icon="paste"></i><i class="arrow" icon="chevron-down"></i></span></span> |
| 117 | <span class="font"><i class="d"></i><span><i icon="styles"></i><i class="arrow" icon="chevron-down"></i></span><i class="combo" style="width: 120px" icon="chevron-down"></i><i class="combo" style="width: 44px" icon="chevron-down"></i><i icon="bold"></i><i icon="italic"></i><i icon="underline"></i> |
| 118 | <span data-fold="8"><span class="full"><span><i icon="strikethrough"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="highlighter"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="font-color"></i><i class="arrow" icon="chevron-down"></i></span><i icon="format-painter"></i></span><span class="folded"><span><i icon="highlighter"></i><i class="arrow" icon="chevron-down"></i></span></span></span></span> |
| 119 | <span data-fold="7"><span class="full"><i class="d"></i><span><i icon="bullets"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="numbering"></i><i class="arrow" icon="chevron-down"></i></span><span><i icon="align-left"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"><span><i icon="bullets"></i><i class="arrow" icon="chevron-down"></i></span></span></span> |
| 120 | <span data-fold="9"><span class="full"><i class="d"></i><i icon="checkbox"></i><i icon="star"></i><i icon="question"></i><i class="more" icon="chevron-down"></i></span><span class="folded"><span><i icon="checkbox"></i><i class="arrow" icon="chevron-down"></i></span></span></span> |
| 121 | <span data-fold="4"><span class="full"><i class="d"></i><span><i icon="table"></i><i class="arrow" icon="chevron-down"></i></span><i icon="picture"></i><i icon="link"></i><i icon="calendar"></i><i icon="equation"></i><span><i icon="symbol"></i><i class="arrow" icon="chevron-down"></i></span><i class="more" icon="chevron-down"></i></span><span class="folded"><span><i icon="plus"></i><i class="arrow" icon="chevron-down"></i></span></span></span> |
| 122 | <span data-fold="0"><span class="full"><i class="d"></i><i icon="attachment"></i></span><span class="folded"></span></span> |
| 123 | <span data-fold="3"><span class="full"><i class="d"></i><i icon="record-audio"></i><i icon="record-video"></i></span><span class="folded"></span></span> |
| 124 | <span data-fold="2"><span class="full"><i class="d"></i><i icon="select"></i><span><i icon="pen"></i><i class="arrow" icon="chevron-down"></i></span><i icon="eraser"></i><i icon="lasso"></i><span><i icon="shapes"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"></span></span> |
| 125 | <span data-fold="1"><span class="full"><i class="d"></i><span><i icon="page-color"></i><i class="arrow" icon="chevron-down"></i></span></span><span class="folded"></span></span> |
| 126 | <span class="space"></span> |
| 127 | <span><i icon="sync-done"></i></span> |
| 128 | <span><i class="more" icon="chevron-down"></i></span> |
| 129 | </div> |
| 130 | <div id="frame"><div id="paper"><p id="status" role="status">Loading Snowbound</p><div id="bar"></div></div></div> |
| 131 | </div> |
| 49 | 132 | <script type="module"> |
| 50 | 133 | import init, * as snowbound from "./snowbound_web.js"; |
| 51 | 134 | |
| 135 | const shell = document.getElementById("shell"); |
| 136 | const status = document.getElementById("status"); |
| 137 | const bar = document.getElementById("bar"); |
| 138 | const tools = document.getElementById("tools"); |
| 139 | // The toolbar's groups fold as the app's do, the lowest priority first, while the row |
| 140 | // overflows. |
| 141 | const groups = [...tools.querySelectorAll("[data-fold]")].sort((a, b) => a.dataset.fold - b.dataset.fold); |
| 142 | const fold = () => { |
| 143 | for (const group of groups) group.classList.remove("fold"); |
| 144 | for (const group of groups) if (tools.scrollWidth > tools.clientWidth) group.classList.add("fold"); |
| 145 | }; |
| 146 | fold(); |
| 147 | addEventListener("resize", fold); |
| 148 | const fail = (message) => { |
| 149 | shell.classList.add("failed"); |
| 150 | status.textContent = message; |
| 151 | }; |
| 152 | |
| 52 | 153 | // Open, metric-compatible faces for the fonts OneNote pages use, fetched beside the module. |
| 53 | 154 | const FONTS = [ |
| 54 | 155 | "Carlito-Regular", "Carlito-Bold", "Carlito-Italic", "Carlito-BoldItalic", |
| ... | ... | @@ -57,18 +158,27 @@ |
| 57 | 158 | "Tinos-Regular", "Tinos-Bold", "Tinos-Italic", "Tinos-BoldItalic", |
| 58 | 159 | "Cousine-Regular", "Cousine-Bold", "Cousine-Italic", "Cousine-BoldItalic", |
| 59 | 160 | ]; |
| 60 | | const bytes = (url) => |
| 61 | | fetch(url).then((response) => { |
| 62 | | if (!response.ok) throw new Error(`${url}: ${response.status}`); |
| 63 | | return response.arrayBuffer(); |
| 161 | const MODULE = "snowbound_web_bg.wasm"; |
| 162 | // Bytes by file, which release_web.py writes in; else the server's Content-Length. |
| 163 | const SIZES = {}; |
| 164 | const sizes = new Map([MODULE, ...FONTS.map((name) => `fonts/${name}.ttf`)].map((url) => [url, SIZES[url]])); |
| 165 | let received = 0; |
| 166 | /** `url`'s response, its body counted into the bar as it arrives. */ |
| 167 | const counted = async (url, headers) => { |
| 168 | const response = await fetch(url); |
| 169 | if (!response.ok) throw new Error(`${url}: ${response.status}`); |
| 170 | if (!sizes.get(url)) sizes.set(url, Number(response.headers.get("Content-Length"))); |
| 171 | const counter = new TransformStream({ |
| 172 | transform(chunk, controller) { |
| 173 | received += chunk.byteLength; |
| 174 | const total = [...sizes.values()].reduce((sum, size) => sum + (size || 0), 0); |
| 175 | bar.style.setProperty("--done", Math.min(received / total, 1)); |
| 176 | controller.enqueue(chunk); |
| 177 | }, |
| 64 | 178 | }); |
| 65 | | // Servers that don't type .wasm as application/wasm can't stream it. |
| 66 | | const module = fetch("snowbound_web_bg.wasm").then((response) => |
| 67 | | response.headers.get("Content-Type")?.startsWith("application/wasm") |
| 68 | | ? response |
| 69 | | : response.arrayBuffer(), |
| 70 | | ); |
| 71 | | const status = document.getElementById("status"); |
| 179 | return new Response(response.body.pipeThrough(counter), { headers: headers ?? response.headers }); |
| 180 | }; |
| 181 | |
| 72 | 182 | // One tab at a time writes the notebooks this browser keeps; the lock lasts as long as the tab. |
| 73 | 183 | const owned = await new Promise((resolve) => { |
| 74 | 184 | if (!navigator.locks) return resolve(true); |
| ... | ... | @@ -78,25 +188,41 @@ |
| 78 | 188 | }); |
| 79 | 189 | }); |
| 80 | 190 | if (!owned) { |
| 81 | | status.textContent = "Snowbound is open in another tab. Close it, then reload this one."; |
| 191 | fail("Snowbound is open in another tab. Close it, then reload this one."); |
| 82 | 192 | throw new Error("Snowbound is open in another tab"); |
| 83 | 193 | } |
| 194 | if (!navigator.gpu && !document.createElement("canvas").getContext("webgl2")) { |
| 195 | fail("This browser can’t draw Snowbound: it has neither WebGPU nor WebGL 2.\nOpen it in a current Chrome, Edge, Safari or Firefox."); |
| 196 | throw new Error("No WebGPU or WebGL 2"); |
| 197 | } |
| 84 | 198 | try { |
| 85 | | const fetched = (url) => bytes(url).then((data) => new Uint8Array(data)); |
| 86 | 199 | const [, fonts, dictionaries] = await Promise.all([ |
| 87 | | init({ module_or_path: module }), |
| 88 | | Promise.all(FONTS.map((name) => fetched(`fonts/${name}.ttf`))), |
| 200 | // Typed here, so the module compiles as it streams in from any server. |
| 201 | counted(MODULE, { "Content-Type": "application/wasm" }).then((module) => init({ module_or_path: module })), |
| 202 | Promise.all(FONTS.map((name) => |
| 203 | counted(`fonts/${name}.ttf`).then((response) => response.arrayBuffer()).then((data) => new Uint8Array(data)))), |
| 89 | 204 | // The spelling dictionaries the site has, fetched as pages need them; without the |
| 90 | 205 | // list, words go unmarked. |
| 91 | 206 | fetch("dictionaries/index.json").then((response) => response.json()).catch(() => []), |
| 92 | 207 | ]); |
| 208 | status.textContent = "Starting Snowbound"; |
| 93 | 209 | await snowbound.start(snowbound, fonts, dictionaries); |
| 94 | | status.remove(); |
| 95 | | console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`); |
| 96 | 210 | } catch (error) { |
| 97 | | status.textContent = `Snowbound could not start: ${error.message ?? error}`; |
| 211 | // A download that failed rejects with a TypeError, or a status `counted` names. |
| 212 | fail(error instanceof TypeError || /: \d{3}$/.test(error.message) |
| 213 | ? "Snowbound couldn’t download. Check your connection, then reload." |
| 214 | : `Snowbound couldn’t start: ${error.message ?? error}`); |
| 98 | 215 | throw error; |
| 99 | 216 | } |
| 217 | // The first frame draws the canvas, under the shell by the second. |
| 218 | for (const _ of [0, 1]) await new Promise(requestAnimationFrame); |
| 219 | shell.classList.add("gone"); |
| 220 | removeEventListener("resize", fold); |
| 221 | setTimeout(() => { |
| 222 | shell.remove(); |
| 223 | snowbound.loaded(); |
| 224 | }, 120); |
| 225 | console.info(`Snowbound loaded in ${Math.round(performance.now())} ms`); |
| 100 | 226 | </script> |
| 101 | 227 | </body> |
| 102 | 228 | </html> |