diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccbe7f6..0bf44de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,9 @@ jobs: cache-dependency-path: apps/web/package-lock.json - name: The runtime holds to its promises on the wasm target too - run: cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets + run: | + cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets + cargo clippy -p hyperlab-web-export --target wasm32-unknown-unknown --all-targets - name: Pack the example stacks as single files run: cargo run -p hyperlab-persistence --example pack_single_files -- apps/web/public/examples diff --git a/.gitignore b/.gitignore index d941b4e..0df1fd1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,5 @@ apps/desktop/src-tauri/target/ # The website's generated assets: the WebAssembly package wasm-pack writes, # and the example stacks packed as single files. Both are built in CI. apps/web/wasm-pkg/ +apps/web/wasm-export-pkg/ apps/web/public/examples/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6a33c0..e8f2b77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,6 +38,7 @@ desktop's components, or `apps/web` itself: ```sh cargo clippy -p hyperlab-web --target wasm32-unknown-unknown --all-targets +cargo clippy -p hyperlab-web-export --target wasm32-unknown-unknown --all-targets cd apps/web npm run prepare:assets # the wasm package and the example stacks diff --git a/Cargo.lock b/Cargo.lock index e4fdcf5..965990d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -580,8 +580,6 @@ dependencies = [ "hyperlab-ai", "hyperlab-ai-providers", "hyperlab-assistant", - "hyperlab-decker", - "hyperlab-export", "hyperlab-graph", "hyperlab-hyperscript", "hyperlab-mcp", @@ -595,6 +593,20 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "hyperlab-web-export" +version = "0.2.0" +dependencies = [ + "console_error_panic_hook", + "hyperlab-decker", + "hyperlab-export", + "hyperlab-persistence", + "hyperlab-stack", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "icu_collections" version = "2.1.1" diff --git a/apps/desktop/src/components/Part.tsx b/apps/desktop/src/components/Part.tsx index ccea32e..ad9424a 100644 --- a/apps/desktop/src/components/Part.tsx +++ b/apps/desktop/src/components/Part.tsx @@ -39,20 +39,29 @@ export function Part({ const [left, top, width, height] = part.rect; const [offset, setOffset] = useState<{ dx: number; dy: number } | null>(null); const origin = useRef({ x: 0, y: 0 }); + /** + * How many screen pixels the card is currently drawing per card pixel. + * + * One, until something shrinks the card to fit — which the website does on + * a phone. A drag reports where the part *ended up in card space*, so the + * pointer's travel has to be divided by this or the part would lag behind + * the finger by exactly the amount the card was scaled. + */ + const scale = useRef(1); const [draft, setDraft] = useMirror(part.text); useEffect(() => { if (offset === null) return undefined; const move = (event: MouseEvent) => setOffset({ - dx: event.clientX - origin.current.x, - dy: event.clientY - origin.current.y, + dx: (event.clientX - origin.current.x) / scale.current, + dy: (event.clientY - origin.current.y) / scale.current, }); const up = (event: MouseEvent) => { - const dx = event.clientX - origin.current.x; - const dy = event.clientY - origin.current.y; + const dx = (event.clientX - origin.current.x) / scale.current; + const dy = (event.clientY - origin.current.y) / scale.current; setOffset(null); - if (dx !== 0 || dy !== 0) onMove(part, left + dx, top + dy); + if (dx !== 0 || dy !== 0) onMove(part, Math.round(left + dx), Math.round(top + dy)); }; window.addEventListener('mousemove', move); window.addEventListener('mouseup', up); @@ -96,6 +105,10 @@ export function Part({ event.preventDefault(); onSelect(part); origin.current = { x: event.clientX, y: event.clientY }; + // Measured at the moment the drag starts, from the part's own + // drawn size against the size it claims to be. + const drawn = event.currentTarget.getBoundingClientRect().width; + scale.current = width > 0 && drawn > 0 ? drawn / width : 1; setOffset({ dx: 0, dy: 0 }); }} > diff --git a/apps/web/package.json b/apps/web/package.json index c3d24b9..ac3e5b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,7 @@ "description": "The HyperLab website: a retro landing page and the full application in the browser.", "license": "MIT", "scripts": { - "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack", + "wasm": "wasm-pack build ../../crates/web --target web --out-dir ../../apps/web/wasm-pkg --out-name hyperlab_web --no-pack && wasm-pack build ../../crates/web-export --target web --out-dir ../../apps/web/wasm-export-pkg --out-name hyperlab_web_export --no-pack", "examples": "cargo run -p hyperlab-persistence --example pack_single_files -- public/examples", "prepare:assets": "npm run wasm && npm run examples", "dev": "vite", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3e7f569..7bbf559 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,7 +8,7 @@ * the example stacks are fetched from the site itself. */ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; import { Assistant } from '../../desktop/src/components/Assistant'; import { Card } from '../../desktop/src/components/Card'; @@ -46,29 +46,106 @@ const EXAMPLES = [ ] as const; /** - * The typeface handed to the exporters, fetched the first time one runs. + * The typeface the exporters draw a picture's words with, fetched the first + * time one runs. * * A page cannot read the machine's fonts, so words drawn inside a stack's - * artwork would come out of a PDF or a deck missing. This is fetched only - * when someone actually exports, so the 400 KB never lands on an ordinary - * visit. A failure is not fatal: the export goes ahead without the labels, - * which is what the desktop does on a machine with no fonts either. + * artwork would come out of a PDF or a deck missing. Fetched only when + * somebody actually exports, so the 400 KB never lands on an ordinary + * visit, and kept afterwards. */ -let fontRegistered: Promise | null = null; +let fontBytes: Promise | null = null; -function withFont(): Promise { - fontRegistered ??= fetch('fonts/LiberationSans-Regular.ttf') +function theFont(): Promise { + fontBytes ??= fetch('fonts/LiberationSans-Regular.ttf') .then((response) => { if (!response.ok) throw new Error(`${response.status}`); return response.arrayBuffer(); }) - .then((buffer) => api.addFont(new Uint8Array(buffer))) - .catch(() => { - // Tried once; a second export should try again rather than inherit + .then((buffer) => new Uint8Array(buffer)) + .catch((reason: unknown) => { + // Tried once; a later export should try again rather than inherit // this failure for the life of the page. - fontRegistered = null; + fontBytes = null; + throw reason; }); - return fontRegistered; + return fontBytes; +} + +/** + * Below this the interface stacks and the panels become sheets. Matches the + * breakpoint in `mobile.css`; they have to agree. + * + * 60rem, because the widest card a stack ships with is 640px and the + * inspector holds 290 beside it — so anything under about 960 cannot show + * the two side by side, landscape phones very much included. + */ +const NARROW = '(max-width: 60rem)'; + +/** + * Whether the screen is narrow enough for the stacked layout. + * + * Watched rather than read once: a phone that turns on its side is a + * different layout, and so is a desktop window dragged narrow. + */ +function useNarrowScreen(): boolean { + const [narrow, setNarrow] = useState( + () => typeof window !== 'undefined' && window.matchMedia(NARROW).matches, + ); + useEffect(() => { + const query = window.matchMedia(NARROW); + const update = () => setNarrow(query.matches); + query.addEventListener('change', update); + return () => query.removeEventListener('change', update); + }, []); + return narrow; +} + +/** + * How much the card has to shrink to fit the room it has. + * + * The stage is measured rather than the window, because the window is not + * what the card sits in: the menu bar, the status bar and any open sheet + * have all taken their share first. A landscape phone is the case that + * makes this matter — plenty of width, almost no height. + * + * Only ever shrinks. A card smaller than the screen is drawn at its own + * size, because artwork drawn one bit deep looks wrong blown up. The answer + * goes to CSS as `--card-zoom`, and `Part.tsx` measures the same scale back + * off the element when a drag starts. + */ +function useCardZoom( + stage: HTMLDivElement | null, + card: { width: number; height: number }, + active: boolean, +): number { + const [room, setRoom] = useState({ width: 0, height: 0 }); + + useEffect(() => { + // Taken as state rather than through a ref, because the stage does not + // exist on the first render — the window shows a notice until the + // runtime is up — and a ref would still be empty when the effect ran. + if (stage === null) return undefined; + const watch = new ResizeObserver(([entry]) => { + if (entry === undefined) return; + const { width, height } = entry.contentRect; + setRoom((was) => + was.width === width && was.height === height ? was : { width, height }, + ); + }); + watch.observe(stage); + return () => watch.disconnect(); + }, [stage]); + + if (!active || card.width <= 0 || card.height <= 0) return 1; + if (room.width === 0 || room.height === 0) return 1; + // The hard shadow the card casts needs a few pixels of its own. + const margin = 6; + return Math.min( + 1, + Math.max(0.2, (room.width - margin) / card.width), + Math.max(0.2, (room.height - margin) / card.height), + ); } /** Hands the user a file, which is what "save" means on a page. */ @@ -119,6 +196,14 @@ export function App() { // every command and re-encoding a card of artwork each time would be // absurd. Cleared when a different stack is opened. const [pictures, setPictures] = useState(new Map()); + const narrow = useNarrowScreen(); + const [stage, setStage] = useState(null); + const cardZoom = useCardZoom(stage, view.cardSize, narrow); + // On a phone the inspector is a sheet over half the screen, so it starts + // out of the way and is asked for. On a desktop it is a column, always + // there, and this is ignored. + const [inspectorOpen, setInspectorOpen] = useState(false); + const showInspector = narrow ? inspectorOpen : true; // The two file pickers, hidden until a menu item clicks them. const stackPicker = useRef(null); @@ -295,30 +380,33 @@ export function App() { ); } - /** The whole stack as a PDF, one page per card. */ + /** + * The whole stack as a PDF, one page per card. + * + * The exporters are a WebAssembly module of their own, fetched here on + * first use, so the notice goes up before the wait rather than after it. + */ function exportPdf() { - withFont() - .then(() => api.exportPdf()) - .then( - (bytes) => { - downloadBytes(`${view.stackName}.pdf`, bytes, 'application/pdf'); - setNotice(`Downloaded ${view.stackName}.pdf`); - }, - (reason: unknown) => setError(String(reason)), - ); + setNotice(`Exporting ${view.stackName}.pdf…`); + api.exportPdf(theFont).then( + (bytes) => { + downloadBytes(`${view.stackName}.pdf`, bytes, 'application/pdf'); + setNotice(`Downloaded ${view.stackName}.pdf`); + }, + (reason: unknown) => setError(String(reason)), + ); } /** The stack as a Decker deck. */ function exportDeck() { - withFont() - .then(() => api.exportDeck()) - .then( - ({ source, notes }) => { - download(`${view.stackName}.deck`, source, 'text/plain'); - setNotice(`Downloaded ${view.stackName}.deck${leftBehind(notes)}`); - }, - (reason: unknown) => setError(String(reason)), - ); + setNotice(`Exporting ${view.stackName}.deck…`); + api.exportDeck(theFont).then( + ({ source, notes }) => { + download(`${view.stackName}.deck`, source, 'text/plain'); + setNotice(`Downloaded ${view.stackName}.deck${leftBehind(notes)}`); + }, + (reason: unknown) => setError(String(reason)), + ); } /** The map as a PNG. Drawn in the window, because only it knows the shape. */ @@ -459,6 +547,19 @@ export function App() { }, ], }, + ...(narrow + ? [ + { + title: 'View', + entries: [ + { + label: inspectorOpen ? 'Hide Inspector' : 'Show Inspector', + run: () => setInspectorOpen((open) => !open), + }, + ] as MenuEntry[], + }, + ] + : []), { title: 'Examples', entries: EXAMPLES.map((name) => ({ @@ -487,11 +588,11 @@ export function App() { ]; return ( -
+
-
+
)} - - run(() => api.setProperty(kind, id, property, value)) - } - onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))} - /> + {showInspector && ( + + run(() => api.setProperty(kind, id, property, value)) + } + onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))} + /> + )}
=> call('export_web'); /** - * Offers a typeface for the words drawn inside pictures. + * The stack as single-file JSON, for the export module to work from. * - * A browser has no system fonts to find, so without one a picture's labels - * are missing from a PDF or a deck. The page fetches a font the first time - * either export is asked for; see {@link withFont} in `App.tsx`. + * Distinct from {@link saveStackText}, which marks the document clean + * because a download *is* the save. Exporting a PDF is not saving. */ -export const addFont = async (bytes: Uint8Array): Promise => { - await (await runtime()).call('add_font', {}, bytes); -}; +export const stackText = (): Promise => call('stack_text'); /** The whole stack as a PDF, one page per card, as the bytes themselves. */ -export const exportPdf = async (): Promise => - (await runtime()).call('export_pdf'); +export const exportPdf = async (font: () => Promise): Promise => { + const { toPdf } = await import('./backend/exporters'); + return toPdf(await stackText(), font); +}; /** The stack as a Decker deck, plus a line for everything Lil and a deck * have no equivalent for. */ -export const exportDeck = (): Promise<{ source: string; notes: string[] }> => - call('export_deck'); +export const exportDeck = async ( + font: () => Promise, +): Promise<{ source: string; notes: string[] }> => { + const { toDeck } = await import('./backend/exporters'); + return toDeck(await stackText(), font); +}; /** An empty snapshot, for the moment before the first one arrives. */ export const emptyView: StackView = { diff --git a/apps/web/src/backend/exporters.ts b/apps/web/src/backend/exporters.ts new file mode 100644 index 0000000..208aa4b --- /dev/null +++ b/apps/web/src/backend/exporters.ts @@ -0,0 +1,78 @@ +/** + * The PDF and Decker exporters, fetched the first time they are wanted. + * + * They are a WebAssembly module of their own because of what they weigh: an + * SVG renderer, a font database and a text shaper, which together are most + * of what HyperLab compiles to. Kept in the main module they were + * downloaded by everybody, including the people who only wanted to click + * through Cluedo. + * + * So the split is along the line where the cost is: opening a stack loads + * the runtime, and exporting one loads this. The stack crosses between them + * as the same single-file JSON it would have been downloaded as, so the two + * modules share no state. + */ + +/** The module, once it has been asked for. */ +let loaded: Promise | null = + null; + +/** The typeface, handed over once per page rather than once per export. */ +let fontGiven = false; + +function load() { + loaded ??= (async () => { + const [module, { default: wasmUrl }] = await Promise.all([ + import('../../wasm-export-pkg/hyperlab_web_export'), + import('../../wasm-export-pkg/hyperlab_web_export_bg.wasm?url'), + ]); + await module.default({ module_or_path: wasmUrl }); + module.init(); + return module; + })(); + return loaded; +} + +/** + * Gives the exporters a typeface for the words drawn inside pictures. + * + * A browser has none of its own to find, so without this a picture's labels + * are missing from whatever comes out. A failure is not fatal — the export + * goes ahead without the labels, which is what a desktop with no fonts + * would also produce — so this never rejects. + */ +async function withFont( + module: Awaited>, + font: () => Promise, +): Promise { + if (fontGiven) return; + try { + module.add_font(await font()); + fontGiven = true; + } catch { + // Tried once, and a later export is welcome to try again. + } +} + +/** The stack as a PDF, one page per card. */ +export async function toPdf( + stackJson: string, + font: () => Promise, +): Promise { + const module = await load(); + await withFont(module, font); + return module.to_pdf(stackJson); +} + +/** The stack as a Decker deck, plus whatever had no equivalent there. */ +export async function toDeck( + stackJson: string, + font: () => Promise, +): Promise<{ source: string; notes: string[] }> { + const module = await load(); + await withFont(module, font); + return JSON.parse(module.to_deck(stackJson)) as { + source: string; + notes: string[]; + }; +} diff --git a/apps/web/src/backend/index.ts b/apps/web/src/backend/index.ts index c6324a5..e065c63 100644 --- a/apps/web/src/backend/index.ts +++ b/apps/web/src/backend/index.ts @@ -231,14 +231,8 @@ async function startFallbackBackend(): Promise { async call(command: string, args?: unknown, bytes?: Uint8Array): Promise { const argsJson = JSON.stringify(args ?? {}); let value: string | undefined; - if (command === 'export_pdf') { - // Bytes, not JSON — the one command that answers with a file. - return module.export_pdf(argsJson) as T; - } if (command === 'import_image_bytes') { value = module.import_image_bytes(argsJson, bytes ?? new Uint8Array()); - } else if (command === 'add_font') { - value = module.add_font(argsJson, bytes ?? new Uint8Array()); } else if (command === 'ai_ask') { value = await module.ai_ask(argsJson); } else { diff --git a/apps/web/src/backend/worker.ts b/apps/web/src/backend/worker.ts index 13a405b..1ffb597 100644 --- a/apps/web/src/backend/worker.ts +++ b/apps/web/src/backend/worker.ts @@ -128,10 +128,6 @@ async function run(message: CallMessage): Promise { switch (message.command) { case 'import_image_bytes': return { value: wasm.import_image_bytes(message.args, given) }; - case 'add_font': - return { value: wasm.add_font(message.args, given) }; - case 'export_pdf': - return { bytes: wasm.export_pdf(message.args) }; case 'ai_ask': return { value: await wasm.ai_ask(message.args) }; default: { diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 02db55c..0a8ff8f 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import '../../desktop/src/theme/neo-classic.css'; +// After the theme, so its narrow-screen rules win. +import './mobile.css'; import { App } from './App'; diff --git a/apps/web/src/mobile.css b/apps/web/src/mobile.css new file mode 100644 index 0000000..6df51ed --- /dev/null +++ b/apps/web/src/mobile.css @@ -0,0 +1,96 @@ +/* + * The playground on a small screen. + * + * Layered over `neo-classic.css` rather than folded into it: that file is + * the desktop application's theme, where the window is never 375 pixels + * wide and every rule here would be dead weight. Nothing below applies + * above the breakpoint, so the desktop draws exactly what it always drew. + * + * The problem it solves: the interface is three columns — stage, assistant, + * inspector — and the inspector is a fixed 290px. On a phone that pushed + * the card off the left edge and left the whole page scrolling sideways. + * + * So on a narrow screen the columns become rows, the card is scaled to fit + * the width it actually has (`--card-zoom`, worked out in `App.tsx`), and + * the panels become sheets across the bottom that the person opens when + * they want them. + */ + +@media (max-width: 60rem) { + /* Stacked, and allowed to shrink: without `min-width: 0` a flex child + refuses to go below its content's width, which is what let a 512px card + push the layout wider than the screen. */ + .app__body { + flex-direction: column; + min-width: 0; + } + + .app__stage { + padding: var(--space-tight); + /* Without this a flex child will not shrink below its content, and the + stage would keep the height a full-size card wants. */ + min-height: 0; + /* The card is scaled to fit, so there is nothing left to scroll to. */ + overflow: hidden; + } + + /* + * `zoom` rather than `transform: scale()`, because zoom reflows: the card + * takes up the room it actually occupies, so the stage centres it and + * nothing overflows. A transform would leave a 512px hole behind. + * + * Part.tsx measures this at the start of a drag, so a part still lands + * where the finger left it. + */ + .card { + zoom: var(--card-zoom, 1); + } + + /* Full width, and never more than half the screen, so the card is always + still visible behind whichever sheet is open. */ + .inspector, + .assistant { + width: auto; + max-height: 50vh; + border-left: none; + border-top: var(--border); + } + + /* Two rows on a phone: the tools and the pager, then the message box + across the full width. */ + .statusbar { + flex-wrap: wrap; + row-gap: var(--space-tight); + } + + .statusbar__message { + flex-basis: 100%; + order: 10; + } + + /* + * Seven menus and two titles do not fit a 375-pixel bar, so the bar wraps + * onto a second line. Scrolling it sideways was the other option and a + * worse one: `overflow-x` makes a clipping context, and the menu that + * drops out of a button was cut off by the very bar it belongs to. + */ + .menubar { + flex-wrap: wrap; + padding: 0 var(--space-hair); + } + + .menubar__title { + padding: 0 var(--space-tight); + } + + /* Wider targets for a thumb, now that there is room for them. */ + .menu__button { + padding: var(--space-hair) var(--space); + } + + /* A dialog is a sheet too, rather than a small box adrift in the middle. */ + .dialog { + width: calc(100vw - 2 * var(--space)); + max-width: none; + } +} diff --git a/crates/web-export/Cargo.toml b/crates/web-export/Cargo.toml new file mode 100644 index 0000000..83df668 --- /dev/null +++ b/crates/web-export/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "hyperlab-web-export" +description = "Turning a stack into a PDF or a Decker deck, in a browser, as a module of its own." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +hyperlab-decker.workspace = true +hyperlab-export.workspace = true +hyperlab-persistence.workspace = true +hyperlab-stack.workspace = true +serde.workspace = true +serde_json.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +console_error_panic_hook = "0.1" +wasm-bindgen = "0.2" + +[lints] +workspace = true + +[package.metadata.wasm-pack.profile.release] +# Same reasoning as `hyperlab-web`: wasm-opt downloads a binaryen release +# mid-build, and a failed download fails the build. +wasm-opt = false diff --git a/crates/web-export/src/lib.rs b/crates/web-export/src/lib.rs new file mode 100644 index 0000000..21cf241 --- /dev/null +++ b/crates/web-export/src/lib.rs @@ -0,0 +1,127 @@ +//! Turning a stack into a PDF or a Decker deck, in a browser. +//! +//! A module of its own, and the reason is weight. Drawing a picture into a +//! PDF page or a deck's bitmap needs an SVG renderer, a font database and a +//! text shaper; together they are most of what HyperLab compiles to in a +//! browser. Left in the main module they were paid for on every visit, +//! including by everyone who only wanted to click through Cluedo. +//! +//! So they live here, and the page fetches this module the first time +//! somebody actually exports something — the same bargain the typeface +//! already made. +//! +//! The seam is the stack's own single-file JSON, which +//! [`hyperlab_persistence`] already reads and writes. The main module hands +//! over the text; this one parses it and exports it. Nothing else crosses, +//! so the two modules share no state and cannot disagree about any. + +#![warn(missing_docs)] + +#[cfg(any(target_arch = "wasm32", test))] +use hyperlab_persistence::stack_from_single_file; +#[cfg(target_arch = "wasm32")] +use serde::Serialize; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::prelude::*; + +/// What a translation produced, and what it could not bring across. +/// +/// Only the WebAssembly boundary has any use for it; the host build of this +/// crate exists to be tested, not to be called. +#[cfg(target_arch = "wasm32")] +#[derive(Serialize)] +struct Exported { + source: String, + notes: Vec, +} + +/// Reads the stack the page sent. +#[cfg(any(target_arch = "wasm32", test))] +fn parse(stack_json: &str) -> Result { + stack_from_single_file(stack_json).map_err(|error| error.to_string()) +} + +// ------------------------------------------------------------------- wasm + +/// Wakes the module up: a panic here should say what it was rather than +/// reaching the page as "unreachable". +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn init() { + console_error_panic_hook::set_once(); +} + +/// Offers a typeface for the words drawn inside pictures. +/// +/// A browser has no system fonts, so without this a picture's labels are +/// missing from whatever comes out. Both exporters are told. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn add_font(bytes: Vec) { + hyperlab_export::add_font(bytes.clone()); + hyperlab_decker::add_font(bytes); +} + +/// The stack as a PDF, one page per card. +/// +/// Answers with the bytes themselves: a PDF is not text, and base64 through +/// a JSON channel would cost a third of the file for nothing. +/// +/// # Errors +/// +/// Returns the exporter's own message if the stack cannot be read or a +/// picture cannot be converted. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn to_pdf(stack_json: &str) -> Result, JsValue> { + let stack = parse(stack_json).map_err(|error| JsValue::from_str(&error))?; + hyperlab_export::to_pdf(&stack).map_err(|error| JsValue::from_str(&error.to_string())) +} + +/// The stack as a Decker deck, with a line for everything Lil and a deck +/// have no equivalent for. +/// +/// # Errors +/// +/// Returns a message if the stack cannot be read. +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen] +pub fn to_deck(stack_json: &str) -> Result { + let stack = parse(stack_json).map_err(|error| JsValue::from_str(&error))?; + let translated = hyperlab_decker::deck(&stack); + serde_json::to_string(&Exported { + source: translated.source, + notes: translated.notes, + }) + .map_err(|error| JsValue::from_str(&error.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The seam this module is reached through, checked on the host: the + /// text the main module sends must come back out as the same stack. + #[test] + fn a_stack_arrives_through_its_own_single_file_json() { + let original = hyperlab_stack::Stack::new("Notes"); + let text = hyperlab_persistence::single_file_string(&original).unwrap(); + + let parsed = parse(&text).expect("the text the other module sends"); + assert_eq!(parsed, original); + + // And the exporters can work with what came across. + assert!(hyperlab_export::to_pdf(&parsed).is_ok()); + assert!(!hyperlab_decker::deck(&parsed).source.is_empty()); + } + + #[test] + fn text_that_is_not_a_stack_is_refused_with_a_reason() { + let error = parse("{ not json").expect_err("that is not a stack"); + assert!( + error.contains("not valid HyperLab JSON"), + "unhelpful: {error}" + ); + } +} diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index 3189544..ee515ca 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -19,8 +19,6 @@ hyperlab-ai.workspace = true # native clients stay out of the build. hyperlab-ai-providers = { path = "../ai-providers", version = "0.2.0", default-features = false } hyperlab-assistant.workspace = true -hyperlab-decker.workspace = true -hyperlab-export.workspace = true hyperlab-graph.workspace = true hyperlab-hyperscript.workspace = true hyperlab-mcp.workspace = true diff --git a/crates/web/src/api.rs b/crates/web/src/api.rs index 2e24e3c..cb625c9 100644 --- a/crates/web/src/api.rs +++ b/crates/web/src/api.rs @@ -911,53 +911,17 @@ pub fn export_web(_args: &str) -> ApiResult { }) } -/// Offers a typeface for the words drawn inside pictures. +/// The stack as single-file JSON, without touching whether it is dirty. /// -/// A browser has no system fonts to find, so without this a picture's -/// labels are missing from a PDF or a deck. The page fetches a font the -/// first time either export is asked for and hands the bytes over here; -/// both exporters keep their own registry, and both are told. +/// What the export module is handed. [`save_stack_json`] cannot serve here: +/// a download *is* the save, so it marks the document clean, and exporting +/// a PDF is not saving the stack. #[wasm_bindgen] -pub fn add_font(_args: &str, bytes: Vec) -> ApiResult { - hyperlab_export::add_font(bytes.clone()); - hyperlab_decker::add_font(bytes); - reply(&()) -} - -/// The whole stack as a PDF, one page per card. -/// -/// Answers with the bytes rather than a string: a PDF is not text, and -/// base64 through a JSON channel would cost a third of the file for -/// nothing. The worker posts the buffer across instead. -/// -/// # Errors -/// -/// Returns the exporter's own message if a picture cannot be converted. -#[wasm_bindgen] -pub fn export_pdf(_args: &str) -> Result, JsValue> { - DOC.with(|cell| { - let held = cell.borrow(); - let doc = held.as_ref().ok_or_else(|| fail("init() has not run"))?; - hyperlab_export::to_pdf(doc.runtime.stack()).map_err(fail) - }) -} - -/// The stack as a Decker deck, with a line for everything Lil and a deck -/// have no equivalent for. -#[wasm_bindgen] -pub fn export_deck(_args: &str) -> ApiResult { - #[derive(Serialize)] - struct Exported { - source: String, - notes: Vec, - } +pub fn stack_text(_args: &str) -> ApiResult { with_doc(|doc| { - let translated = hyperlab_decker::deck(doc.runtime.stack()); - serde_json::to_string(&Exported { - source: translated.source, - notes: translated.notes, - }) - .map_err(|error| error.to_string()) + let text = hyperlab_persistence::single_file_string(doc.runtime.stack()) + .map_err(|error| error.to_string())?; + serde_json::to_string(&text).map_err(|error| error.to_string()) }) } diff --git a/docs/website.md b/docs/website.md index 75b70b1..fddd9c9 100644 --- a/docs/website.md +++ b/docs/website.md @@ -92,10 +92,26 @@ hyperlab_decker::add_font(bytes); ``` The site fetches Liberation Sans (OFL, in `public/fonts/`) the first time -someone exports, so the 400 KB never lands on an ordinary visit, and hands -it to both. A registered font is loaded *in addition* to the machine's own -and only stands in for the generic families where the platform found -nothing — so a desktop export is byte-for-byte what it always was. +someone exports and hands it to both. A registered font is loaded *in +addition* to the machine's own, and only stands in for the generic families +where they resolve to nothing as they stand — which is every browser, and +also a stock Linux box with no Arial. A machine that can already draw +sans-serif keeps whatever it was drawing it with. + +### Why the exporters are a module of their own + +An SVG renderer, a font database and a text shaper come with them, and +together they are most of what HyperLab compiles to: with the exporters in +the main module the runtime was 1.6 MB over the wire, paid for by everyone +who only wanted to click through Cluedo. Split out, the runtime is 326 KB +and `crates/web-export` is fetched the first time somebody exports +something. + +The seam is the stack's own single-file JSON. `stack_text` hands the text +over — distinct from `save_stack_json`, which marks the document clean +because a download *is* the save — and the export module parses it and works +from that. Nothing else crosses, so the two modules share no state and +cannot disagree about any. ## Keys never leave the browser