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 @@ -258,7 +258,9 @@ jobs:
- name: Compile web (wasm) build
if: matrix.name != 'Windows'
shell: bash
run: bash scripts/ci-retry.sh zig build check-web
run: |
bash scripts/ci-retry.sh zig build check-web
scripts/test-web-text-input.sh --embed-only

# `check-web` sets no `-Dapp-layout`, so the web build's layout wiring is a no-op there; this
# builds wasm with an app-owned `.zon` and `.zig` layout, and fails if that wiring is gone.
Expand Down
9 changes: 7 additions & 2 deletions scripts/test-web-text-input.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
#!/bin/sh
# The hidden input's tests (web/tests/text-input.test.js) against the web.js the web build
# writes: `zig build web` first. Needs node, or jsc on macOS.
# writes: `zig build web` first. Node also runs the two-copy embed check.
# Use --embed-only for the build-independent Node check; hidden-input tests also support jsc.
set -eu
cd "$(dirname "$0")/.."
if [ "${1:-}" = --embed-only ]; then
exec node web/tests/embed.test.mjs
fi
js=zig-out/web/web.js
[ -f "$js" ] || { echo "test-web-text-input: no $js; run zig build web" >&2; exit 1; }
if command -v node >/dev/null; then
exec node web/tests/text-input.test.js "$PWD/$js"
node web/tests/text-input.test.js "$PWD/$js"
exec node web/tests/embed.test.mjs
fi
jsc=/System/Library/Frameworks/JavaScriptCore.framework/Versions/Current/Helpers/jsc
[ -x "$jsc" ] || { echo "test-web-text-input: needs node (or macOS's jsc)" >&2; exit 1; }
Expand Down
4 changes: 4 additions & 0 deletions web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Z
turns its query (`?storage=`, `?persist=`, `?worker=`, `?form=`, `?plugin=`, ...) into options
and shows `onReady`/`onError` itself, and an app built on fizzy passes its own. Nothing wraps
`WebAssembly.instantiateStreaming`.
- **Several copies share a page.** `fizzy.js` keeps no per-page state but the compiled wasm (shared by
URL) and the last copy used. A window-level listener must check its event is for this copy
(`ownsKey`), and full screen takes `fullscreenElement` (default the canvas's parent), never the
document. `node web/tests/embed.test.mjs` mounts two copies against a fake DOM (CI runs it).
- **`web.js` is dvui's,** with `patches/dvui` applied at build time (`docs/DEPENDENCIES.md`). A
change to it is a change to a patch: make it on the dvui branch the patches come from and export
them again, so it can go upstream. Never keep a copy of `web.js` here.
Expand Down
86 changes: 71 additions & 15 deletions web/fizzy.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@ import { Dvui, TextInput } from "./web.js?cache-buster=TEMPLATE_HASH_WITH_PADDIN
// overwrites the real one's settings, layout or plugins. Stamped by the build.
const buildStorageName = "__FIZZY_WEB_STORAGE__";

/** The editor's compiled wasm by URL, shared by every copy on the page: one download, one compile. */
const compiledEditors = new Map();

/**
* The compiled editor at `url`, started on the first ask and shared after. Null when it could not
* be fetched or compiled (a later copy tries again); the caller then fetches it itself.
* @param {string} url
* @returns {Promise<WebAssembly.Module | null>}
*/
function compiledEditor(url) {
let compiled = compiledEditors.get(url);
if (!compiled) {
compiled = WebAssembly.compileStreaming(fetch(url)).catch((err) => {
console.warn("fizzy: compiling the editor early failed; the copy fetches it", err);
compiledEditors.delete(url);
return null;
});
compiledEditors.set(url, compiled);
}
return compiled;
}

/** The copy whose canvas or input was last used, which gets keys that land on the page itself. */
let activeCopy = null;

/**
* @typedef {object} FizzyOptions
* @property {HTMLCanvasElement} canvas - Where the editor draws and takes its input.
Expand All @@ -31,6 +56,8 @@ const buildStorageName = "__FIZZY_WEB_STORAGE__";
* Default: in a worker on Chromium only. Never in a worker with `imports`.
* @property {"desktop" | "tablet" | "mobile"} [form] - The form factor to take, in place of the
* device's (`core.platform.formFactor()`). Anything else is ignored.
* @property {HTMLElement} [fullscreenElement] - What View › Toggle Full Screen shows full screen.
* Default: the canvas's parent, so an embed fills the screen and the page around it does not.
* @property {string[]} [plugins] - Ids of plugins to load from `plugins/<id>/<id>.wasm` beside the
* app once it is up, each in place of an installed one of the same id: how a developer tries a
* build.
Expand Down Expand Up @@ -268,11 +295,7 @@ export class Fizzy {
// View › Toggle Full Screen. The request runs a frame after the key press or click
// that asked for it, which is still inside the browser's user-activation window.
fizzy_web_toggle_fullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
} else if (document.documentElement.requestFullscreen) {
document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err));
}
toggleFullscreen();
},
// `Clock.real` for the app's `Io` (src/backend/web_io.zig).
fizzy_web_now_real_ms() {
Expand Down Expand Up @@ -785,13 +808,40 @@ export class Fizzy {
// "pinch fingers together = zoom out".
let dvuiApp = null;
const dvuiCanvas = opts.canvas;
// The element dvui's text input lives in (set once the editor is up): where text arrives.
let textInput = null;
for (const type of ["pointerdown", "focus"]) dvuiCanvas.addEventListener(type, () => { activeCopy = this; }, { capture: true });

// View › Toggle Full Screen shows this copy's element, not the whole page, and switches to it
// when another element (another copy) is already full screen.
function fullscreenTarget() {
return opts.fullscreenElement ?? dvuiCanvas.parentElement ?? dvuiCanvas;
}
function toggleFullscreen() {
const target = fullscreenTarget();
if (document.fullscreenElement === target) {
document.exitFullscreen().catch(() => {});
} else if (target.requestFullscreen) {
target.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err));
}
}

// Shortcuts the app binds are the app's, not the browser's: ⌘S must not also save the
// page. Capture phase, so this runs before dvui's own listener (which still receives the
// key — preventDefault stops the browser, not the event). The app answers synchronously
// from its keymap (`Keybinds.webKeyBound`), which also keeps typing, copy/cut/paste and
// unbound chords with the browser.
//
// Only the keys meant for this copy: typed into the page's own field, or a chord aimed at
// another copy, they stay the page's. A key lands on this copy when its canvas or its text
// input has the focus, or when nothing has and this copy was the last one used.
const ownsKey = (ev) => {
const target = ev.target;
if (target === dvuiCanvas || (textInput && target === textInput)) return true;
return (target === document.body || target === document.documentElement) && activeCopy === this;
};
window.addEventListener("keydown", (ev) => {
if (!ownsKey(ev)) return;
const ex = wasmInstance && wasmInstance.exports;
if (!ex || !ex.FizzyWebKeyBound) return;
const bytes = utf8encode(ev.key);
Expand All @@ -806,9 +856,13 @@ export class Fizzy {
// and those chords stay the browser's.
document.addEventListener("fullscreenchange", () => {
const kb = navigator.keyboard;
const full = document.fullscreenElement === fullscreenTarget();
// The text input is the page's, outside the element shown full screen, where it cannot
// take focus; it goes in with the editor and comes out again.
if (textInput) (full ? fullscreenTarget() : document.body).prepend(textInput);
if (!kb || !kb.lock) return;
if (document.fullscreenElement) kb.lock().catch(() => {});
else kb.unlock();
if (full) kb.lock().catch(() => {});
else if (!document.fullscreenElement) kb.unlock();
});
dvuiCanvas.addEventListener("wheel", (ev) => {
if (ev.ctrlKey && ev.deltaMode === 0) {
Expand Down Expand Up @@ -898,6 +952,7 @@ export class Fizzy {
hidden.setAttribute("autocapitalize", "none");
Object.assign(hidden.style, { position: "absolute", left: 0, top: 0, padding: 0, border: 0, margin: 0, opacity: 0, zIndex: -1 });
document.body.prepend(hidden);
textInput = hidden;
let textInputRect = [];
// Focus never scrolls the page; and a copy embedded in another page (fizzyed.it's
// examples, in an iframe) takes none until it is clicked, tapped or tabbed into.
Expand Down Expand Up @@ -1019,8 +1074,7 @@ export class Fizzy {
}
break;
case "fullscreen":
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
else if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen().catch((err) => console.warn("fizzy: full screen refused:", err));
toggleFullscreen();
break;
case "oauth": {
let blobUrl = null;
Expand Down Expand Up @@ -1191,10 +1245,7 @@ export class Fizzy {
// editor's download used to wait with it, tens of seconds on a slow link. The worker is
// handed the compiled module and fetches nothing itself, so a page shown at once never
// downloads it twice. Null on failure: the worker then fetches it as before.
const wasmModule = WebAssembly.compileStreaming(fetch(wasmUrl)).catch((err) => {
console.warn("fizzy: compiling the editor early failed; the worker fetches it", err);
return null;
});
const wasmModule = compiledEditor(wasmUrl);
waitForLayout().then(() => startWorker(wasmUrl, wasmModule)).catch((err) => {
console.error(err);
onError(`Failed to load the editor (${err instanceof Error ? err.message : String(err)}).`);
Expand All @@ -1204,17 +1255,22 @@ export class Fizzy {
waitForLayout()
.then(() => {
const app = new Dvui();
return WebAssembly.instantiateStreaming(fetch(wasmUrl), {
const imports = {
...opts.imports,
dvui: app.imports,
fizzy: fizzyImports(() => wasmInstance),
env: { __indirect_function_table: functionTable },
}).then((result) => {
};
return compiledEditor(wasmUrl).then((module) => module
? WebAssembly.instantiate(module, imports).then((instance) => ({ instance }))
: WebAssembly.instantiateStreaming(fetch(wasmUrl), imports)
).then((result) => {
wasmInstance = result.instance;
if (opts.onInstance) opts.onInstance(result.instance);
app.setInstance(result.instance);
app.setCanvas(dvuiCanvas);
app.run();
textInput = app.hidden_input ?? null;
return app;
});
})
Expand Down
136 changes: 136 additions & 0 deletions web/tests/embed.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Two copies of fizzy on one page, against a fake DOM and a fake wasm: the page's own fields keep
// their keys, a chord goes to the copy it is aimed at, full screen takes the embed, and the editor
// is fetched and compiled once. Run: `node web/tests/embed.test.mjs`.

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";

// fizzy.js as the build stamps it, beside a `web.js` that is only a stand-in for dvui's.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fizzy-embed-"));
const source = fs.readFileSync(new URL("../fizzy.js", import.meta.url), "utf8")
.replace("__FIZZY_WEB_STORAGE__", "test")
.replaceAll("?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256", "");
fs.writeFileSync(path.join(dir, "fizzy.js"), source);
fs.writeFileSync(path.join(dir, "web.js"), `
export class TextInput {
keydown() {}
clear() {}
}
export class Dvui {
constructor() { this.imports = {}; this.hidden_input = null; }
setInstance(instance) { this.instance = instance; }
setCanvas(canvas) { this.canvas = canvas; }
run() { this.hidden_input = globalThis.document.createElement("input"); }
requestRender() {}
}
`);

class Target {
constructor() { this.listeners = {}; this.children = []; this.parentElement = null; }
addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); }
removeEventListener() {}
dispatch(type, ev) { for (const fn of this.listeners[type] ?? []) fn(ev); }
prepend(child) { child.parentElement = this; this.children.unshift(child); }
}
class Element extends Target {
constructor() { super(); this.style = {}; this.fullscreen = 0; }
setAttribute() {}
focus() {}
getBoundingClientRect() { return { left: 0, top: 0, right: 100, bottom: 100 }; }
requestFullscreen() { document.fullscreenElement = this; this.fullscreen++; document.dispatch("fullscreenchange", {}); return Promise.resolve(); }
}
class Canvas extends Element {
constructor() { super(); this.clientWidth = 100; this.clientHeight = 100; }
getContext() { return {}; }
}
const document = Object.assign(new Target(), {
fullscreenElement: null,
baseURI: "http://localhost/",
body: new Element(),
documentElement: new Element(),
createElement: (tag) => (tag === "canvas" ? new Canvas() : new Element()),
exitFullscreen() { document.fullscreenElement = null; document.dispatch("fullscreenchange", {}); return Promise.resolve(); },
});
const window = Object.assign(new Target(), {
devicePixelRatio: 1, scrollX: 0, scrollY: 0, localStorage: new Map(), parent: null,
dispatchEvent() {}, matchMedia: () => ({ matches: false, addEventListener() {} }),
});
window.parent = window;
Object.assign(globalThis, {
window, document, HTMLCanvasElement: Canvas, Event: class {}, requestAnimationFrame: (f) => setTimeout(f, 0),
screen: { width: 800, height: 600 }, TextEncoder, TextDecoder, location: { href: "http://localhost/" },
});
Object.defineProperty(globalThis, "navigator", { value: {}, configurable: true });

// The editor: a module that answers FizzyWebKeyBound for the chord Ctrl+S, counting its asks.
let fetches = 0;
let compiles = 0;
const bound = [];
const toggles = [];
const memory = new WebAssembly.Memory({ initial: 1 });
globalThis.fetch = (url) => { fetches++; return Promise.resolve({ url }); };
WebAssembly.compileStreaming = async () => { compiles++; return { fake: true }; };
WebAssembly.instantiate = async (module, imports) => {
const id = bound.length;
bound.push([]);
toggles.push(imports.fizzy.fizzy_web_toggle_fullscreen);
return {
exports: {
memory: { buffer: memory.buffer },
FizzyWebKeyBuffer: () => 0,
FizzyWebKeyBound: (len, mods) => { bound[id].push([len, mods]); return 1; },
},
};
};
WebAssembly.Table = class {};

const { Fizzy } = await import(pathToFileURL(path.join(dir, "fizzy.js")).href);

const mount = async (parent) => {
const canvas = document.createElement("canvas");
canvas.parentElement = parent;
const ready = new Promise((resolve, reject) => {
new Fizzy({ canvas, worker: false, persist: false, onReady: resolve, onError: reject, onInstance: () => {} }).run();
});
await ready;
return canvas;
};
const a = await mount(new Element());
const b = await mount(new Element());

assert.equal(compiles, 1, "two copies share one compile");
assert.equal(fetches, 1, "two copies share one download");

const key = (target) => {
const ev = { target, key: "s", ctrlKey: true, metaKey: false, altKey: false, shiftKey: false, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
window.dispatch("keydown", ev);
return ev;
};

const field = new Element();
assert.equal(key(field).defaultPrevented, false, "a chord typed into the page's own field stays the page's");
assert.deepEqual(bound, [[], []], "the editors were not asked");

assert.equal(key(a).defaultPrevented, true, "a chord on the first copy is its");
assert.deepEqual(bound.map((asks) => asks.length), [1, 0], "only the copy it was aimed at is asked");
assert.equal(key(b).defaultPrevented, true);
assert.deepEqual(bound.map((asks) => asks.length), [1, 1]);

b.dispatch("pointerdown", {});
assert.equal(key(document.body).defaultPrevented, true, "with nothing focused the last copy used has the key");
assert.deepEqual(bound.map((asks) => asks.length), [1, 2]);

// View › Toggle Full Screen: the copy's own element, not the page; the other copy switches to its own.
const parentA = a.parentElement;
const parentB = b.parentElement;
toggles[0]();
assert.equal(document.fullscreenElement, parentA, "full screen shows the embed, not the page");
assert.equal(document.documentElement.fullscreen, 0);
toggles[1]();
assert.equal(document.fullscreenElement, parentB, "the other copy takes full screen for itself");
toggles[1]();
assert.equal(document.fullscreenElement, null, "and a second toggle leaves it");
console.log("embed.test: ok");
Loading