Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 14 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 18 additions & 5 deletions apps/desktop/src/components/Part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand 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 });
}}
>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
191 changes: 147 additions & 44 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> | null = null;
let fontBytes: Promise<Uint8Array> | null = null;

function withFont(): Promise<void> {
fontRegistered ??= fetch('fonts/LiberationSans-Regular.ttf')
function theFont(): Promise<Uint8Array> {
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. */
Expand Down Expand Up @@ -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<string, string>());
const narrow = useNarrowScreen();
const [stage, setStage] = useState<HTMLDivElement | null>(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<HTMLInputElement>(null);
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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) => ({
Expand Down Expand Up @@ -487,11 +588,11 @@ export function App() {
];

return (
<div className="app">
<div className="app" style={{ '--card-zoom': cardZoom } as CSSProperties}>
<MenuBar view={view} menus={menus} />

<div className="app__body">
<div className="app__stage">
<div className="app__stage" ref={setStage}>
<Card
view={view}
tool={tool}
Expand Down Expand Up @@ -519,15 +620,17 @@ export function App() {
/>
)}

<Inspector
view={view}
selection={selection}
onSelect={setSelection}
onSetProperty={(kind, id, property, value) =>
run(() => api.setProperty(kind, id, property, value))
}
onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))}
/>
{showInspector && (
<Inspector
view={view}
selection={selection}
onSelect={setSelection}
onSetProperty={(kind, id, property, value) =>
run(() => api.setProperty(kind, id, property, value))
}
onSetScript={(kind, id, script) => run(() => api.setScript(kind, id, script))}
/>
)}
</div>

<StatusBar
Expand Down
Loading
Loading