Merge pull request #3591 from helenkwok/page-host-after-2315

feat(web): page host mode, so a page can host LightCraft in its own document (builds on #2315)
This commit is contained in:
FredApps
2026-11-05 15:31:37 +01:00
committed by GitHub
6 changed files with 366 additions and 67 deletions
+4
View File
@@ -32,10 +32,14 @@ js-sys = { workspace = true }
web-sys = { workspace = true, features = [
"Blob",
"BlobPropertyBag",
"CustomEvent",
"CustomEventInit",
"CssStyleDeclaration",
"DedicatedWorkerGlobalScope",
"Document",
"DomStringList",
"Element",
"Event",
"EventTarget",
"File",
"FileList",
+275 -53
View File
@@ -1,6 +1,6 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! LightCraft in an `<iframe>` (issue #2270). The protocol is the one PdfCraft, GridCraft and
//! DeckCraft speak, with `lightcraft:` message types:
//! The bridge to a host page (issue #2270): a same-origin page embeds LightCraft in an `<iframe>`
//! (`?host=parent`) or in its own document (`data-host="page"` on the canvas). The protocol is
//! the one PdfCraft, GridCraft and DeckCraft speak, with `lightcraft:` message types:
//!
//! - `lightcraft:ready` (to the parent): `{ version }`, the app is listening.
//! - `lightcraft:failed` (to the parent): `{ error }`, the app couldn't start, or it refused a
@@ -29,8 +29,17 @@
//! either: its `location` cannot be read, and exports keep downloading. There is no `docClosed`: LightCraft has a
//! library, not documents that close.
//!
//! ## `data-host="page"` — the page's own document
//!
//! A page can also run LightCraft in its own document, no iframe: the canvas opts in with
//! `data-host="page"`, and the same messages travel as DOM `CustomEvent`s on the canvas instead
//! of `postMessage` — outward with the message's fields as the event's `detail`, inward as
//! `lightcraft:open` and `lightcraft:cmd` events the page dispatches on the canvas. Events don't
//! bubble, so two editors on one page can't hear each other, and the canvas is the only target
//! the bridge listens on.
//!
//! The message layer (what a message means, what is sent back) is plain Rust, tested natively;
//! [`Bridge`] (wasm32 only) moves it over `postMessage`.
//! [`Bridge`] (wasm32 only) moves it over `postMessage`, or over DOM events in page mode.
/// A message from the parent page, once understood.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -138,6 +147,25 @@ impl DirtyReport {
pub fn sent(&mut self, dirty: bool) {
self.reported = Some(dirty);
}
/// Record `dirty` before the send. A page host can report another state while the event is
/// still dispatching, and that later report wins. Returns the message to send and the state
/// to put back if this send fails and nothing else recorded one.
pub fn prepare(&mut self, dirty: bool) -> (Option<Outgoing>, Option<bool>) {
let previous = self.reported;
let message = self.change(dirty);
if message.is_some() {
self.sent(dirty);
}
(message, previous)
}
/// Forget a send that failed, unless a nested report already recorded a different state.
pub fn restore_if_unchanged(&mut self, dirty: bool, previous: Option<bool>) {
if self.reported == Some(dirty) {
self.reported = previous;
}
}
}
/// Whether a page origin can tell the parent apart from any other window: an opaque origin
@@ -152,6 +180,48 @@ pub fn save_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| path.to_string())
}
/// The one `data-host` value that opts a page into page host mode: the exact string `"page"`.
pub const PAGE_HOST: &str = "page";
/// How a host page reaches the app. The modes behave the same; they differ only in how an event
/// goes out ([`Bridge::post`] and `save`, wasm32) and how one comes in ([`Bridge::listen`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Mode {
/// `?host=parent`: the page framed LightCraft in an `<iframe>`; `postMessage`.
Parent,
/// `data-host="page"`: the page runs LightCraft in its own document; DOM `CustomEvent`s.
Page,
}
/// The mode the page asked for. `?host=parent` wins when the page asks for both, the same order
/// as the other apps. Page mode is the exact `data-host="page"` ([`PAGE_HOST`]). `None` with
/// neither, so that URL stays the normal app with downloads.
pub fn transport_for(data_host: Option<&str>, query: &str) -> Option<Mode> {
if host_parent_requested(query) {
Some(Mode::Parent)
} else if data_host == Some(PAGE_HOST) {
Some(Mode::Page)
} else {
None
}
}
/// The message type. A page-mode `CustomEvent` is named `lightcraft:open` or `lightcraft:cmd`
/// and its `detail` is only the fields, so the event name is the type. A parent `postMessage`
/// carries `type` in the object and has no event name.
pub fn incoming_kind(event_name: Option<&str>, detail_type: Option<String>) -> Option<String> {
event_name.map(str::to_string).or(detail_type)
}
/// Does the query ask for the parent bridge? The same reading of `?host=parent` the URL options
/// always used: the first `host` parameter whose value is exactly `parent`.
pub fn host_parent_requested(query: &str) -> bool {
query.trim_start_matches('?').split('&').find_map(|p| {
let (k, v) = p.split_once('=').unwrap_or((p, ""));
(k == "host").then_some(v == "parent")
}) == Some(true)
}
#[cfg(target_arch = "wasm32")]
pub use bridge::Bridge;
@@ -164,22 +234,61 @@ mod bridge {
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
use super::{DirtyReport, Field, Incoming, Outgoing, Raw, open_size_error, origin_is_usable, save_name};
use super::{DirtyReport, Field, Incoming, Mode, Outgoing, Raw, open_size_error, origin_is_usable, save_name};
/// The parent page's end of the bridge (cheap to clone; one per page).
/// How an event goes to the host page, per mode ([`Mode`]).
#[derive(Clone)]
enum Transport {
/// `?host=parent`: `postMessage` between same-origin windows.
Parent { parent: web_sys::Window, origin: String },
/// `data-host="page"`: DOM `CustomEvent`s on the editor's own canvas.
Page { canvas: web_sys::HtmlCanvasElement },
}
impl Transport {
/// Hand an event to the host. Parent mode: a `postMessage` whose `type` field is `event`
/// (the same message as before, transferring `transfer` when given). Page mode: a
/// non-bubbling `CustomEvent` of that name on the canvas, whose `detail` holds the
/// fields; nothing is transferred there, the buffer just rides inside `detail`.
fn send(&self, event: &str, fields: &[(&str, JsValue)], transfer: Option<&js_sys::ArrayBuffer>) -> Result<(), String> {
match self {
Transport::Parent { parent, origin } => {
let mut all = Vec::with_capacity(fields.len() + 1);
all.push(("type", JsValue::from_str(event)));
all.extend_from_slice(fields);
let message = message(&all).ok_or_else(|| "couldn't build the message".to_string())?;
let sent = match transfer {
Some(buffer) => parent.post_message_with_transfer(&message, origin, &js_sys::Array::of1(buffer)),
None => parent.post_message(&message, origin),
};
sent.map_err(|e| js_error(&e, "couldn't post the message"))
}
Transport::Page { canvas } => {
let detail = message(fields).ok_or_else(|| "couldn't build the event detail".to_string())?;
let init = web_sys::CustomEventInit::new();
init.set_bubbles(false);
init.set_detail(&detail);
let custom =
web_sys::CustomEvent::new_with_event_init_dict(event, &init).map_err(|e| js_error(&e, "couldn't create the event"))?;
canvas.dispatch_event(&custom).map_err(|e| js_error(&e, "couldn't dispatch the event"))?;
Ok(())
}
}
}
}
/// The host page's end of the bridge (cheap to clone; one per page).
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// What the parent sent since the last frame, oldest first.
transport: Transport,
/// What the host sent since the last frame, oldest first.
incoming: Rc<RefCell<Vec<Incoming>>>,
dirty: Rc<RefCell<DirtyReport>>,
}
impl Bridge {
/// `None` when the page isn't framed, its origin is opaque, or the parent is on another
/// origin (exports then keep downloading).
/// The parent bridge for `?host=parent`. `None` when the page isn't framed, its origin is
/// opaque, or the parent is on another origin (exports then keep downloading).
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
@@ -196,41 +305,60 @@ mod bridge {
if parent.location().origin().ok().as_deref() != Some(origin.as_str()) {
return None;
}
Some(Self { window, parent, origin, incoming: Rc::default(), dirty: Rc::default() })
Some(Self { transport: Transport::Parent { parent, origin }, incoming: Rc::default(), dirty: Rc::default() })
}
/// Hear the parent from now on; a message wakes the app for the next frame.
/// The page bridge for `data-host="page"`: the host page runs LightCraft in its own
/// document and hears the app's DOM `CustomEvent`s on this canvas.
pub fn for_page(canvas: &web_sys::HtmlCanvasElement) -> Self {
Self { transport: Transport::Page { canvas: canvas.clone() }, incoming: Rc::default(), dirty: Rc::default() }
}
/// The bridge for the mode the page opted into (`host::transport_for`): page mode needs
/// its canvas, and parent mode falls back to `None` as [`Bridge::new`] always did.
pub fn for_mode(mode: Mode, canvas: Option<&web_sys::HtmlCanvasElement>) -> Option<Self> {
match mode {
Mode::Page => canvas.map(Self::for_page),
Mode::Parent => Self::new(),
}
}
/// Hear the host from now on; a message wakes the app for the next frame.
pub fn listen(&self, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let incoming = self.incoming.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
match &self.transport {
Transport::Parent { parent, origin } => {
let parent = parent.clone();
let origin = origin.clone();
let incoming = self.incoming.clone();
let on_message = Closure::<dyn Fn(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
deliver(&incoming, &ctx, &event.data(), None);
});
if web_sys::window().is_some_and(|w| w.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok()) {
// the listener lives as long as the page
on_message.forget();
}
}
let data = event.data();
// moved, not cloned: an accepted photo can be hundreds of MB
let (bytes, oversize) = match bytes(&data) {
Some(Ok(b)) => (Some(b), None),
Some(Err(len)) => (None, Some(len)),
None => (None, None),
};
let raw = Raw {
kind: string(&data, "type"),
name: string(&data, "name"),
bytes,
id: string(&data, "id"),
params: string(&data, "params"),
oversize,
};
if let Some(m) = Incoming::parse(raw) {
incoming.borrow_mut().push(m);
ctx.request_repaint();
Transport::Page { canvas } => {
// non-bubbling events: the page dispatches on the canvas, the canvas listens
for kind in ["lightcraft:open", "lightcraft:cmd"] {
let incoming = self.incoming.clone();
let ctx = ctx.clone();
let event_type = kind.to_string();
// `Fn`, not `FnMut`: a page event is dispatched synchronously, and a host
// that answers one event with another would re-enter an `FnMut` listener.
let on_custom = Closure::<dyn Fn(web_sys::Event)>::new(move |event: web_sys::Event| {
let Some(custom) = event.dyn_ref::<web_sys::CustomEvent>() else { return };
deliver(&incoming, &ctx, &custom.detail(), Some(event_type.as_str()));
});
if canvas.add_event_listener_with_callback(kind, on_custom.as_ref().unchecked_ref()).is_ok() {
// the listener lives as long as the page
on_custom.forget();
}
}
}
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// the listener lives as long as the page
on_message.forget();
}
}
@@ -241,17 +369,24 @@ mod bridge {
/// Post a message; false if it couldn't be sent.
pub fn post(&self, m: &Outgoing) -> bool {
message(&m.fields().iter().map(|(k, v)| (*k, field(v))).collect::<Vec<_>>())
.is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
let fields = m.fields();
// every message names its type first (tested natively); the rest go to the transport
let Some((_, Field::Str(event))) = fields.first() else { return false };
let event = event.clone();
let rest: Vec<(&str, JsValue)> = fields.into_iter().skip(1).map(|(k, v)| (k, field(&v))).collect();
self.transport.send(&event, &rest, None).is_ok()
}
/// Tell the parent whether closing now would lose something, when that changed.
/// The new state is recorded before the send: page mode dispatches synchronously, and a
/// host that reports another state from that event wins. A failed send is forgotten
/// only when nothing else recorded one, so the next frame retries it.
pub fn report_dirty(&self, dirty: bool) {
let mut report = self.dirty.borrow_mut();
if let Some(m) = report.change(dirty)
&& self.post(&m)
let (message, previous) = self.dirty.borrow_mut().prepare(dirty);
if let Some(m) = message
&& !self.post(&m)
{
report.sent(dirty);
self.dirty.borrow_mut().restore_if_unchanged(dirty, previous);
}
}
@@ -269,16 +404,36 @@ mod bridge {
}
fn transfer(&self, name: &str, buffer: js_sys::ArrayBuffer) -> Result<(), String> {
let message = message(&[("type", "lightcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
self.transport.send("lightcraft:save", &[("name", name.into()), ("bytes", buffer.clone().into())], Some(&buffer))?;
log::info!("handed {name} to the page");
Ok(())
}
}
/// A message from the host (the `data` of a window message, or the `detail` of a page-mode
/// `CustomEvent`), read with the same field code both transports share, and pushed for the
/// next frame.
fn deliver(incoming: &Rc<RefCell<Vec<Incoming>>>, ctx: &egui::Context, data: &JsValue, event_type: Option<&str>) {
// moved, not cloned: an accepted photo can be hundreds of MB
let (bytes, oversize) = match bytes(data) {
Some(Ok(b)) => (Some(b), None),
Some(Err(len)) => (None, Some(len)),
None => (None, None),
};
let raw = Raw {
kind: super::incoming_kind(event_type, string(data, "type")),
name: string(data, "name"),
bytes,
id: string(data, "id"),
params: string(data, "params"),
oversize,
};
if let Some(m) = Incoming::parse(raw) {
incoming.borrow_mut().push(m);
ctx.request_repaint();
}
}
fn field(v: &Field) -> JsValue {
match v {
Field::Str(s) => s.as_str().into(),
@@ -313,6 +468,11 @@ mod bridge {
let len = u64::from(array.byte_length());
Some(if open_size_error(len).is_some() { Err(len) } else { Ok(array.to_vec()) })
}
/// A `JsValue` error as a string, after `what` went wrong.
fn js_error(e: &JsValue, what: &str) -> String {
format!("{what}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
}
#[cfg(test)]
@@ -390,6 +550,33 @@ mod tests {
assert_eq!(d.change(false), Some(Outgoing::Dirty(false)));
}
/// A failed send is retried. A report that lands during the send (page mode dispatches
/// synchronously) is the one that stays.
#[test]
fn a_failed_dirty_send_is_retried_unless_a_later_report_landed() {
let mut d = DirtyReport::default();
let (message, previous) = d.prepare(true);
assert_eq!(message, Some(Outgoing::Dirty(true)));
d.restore_if_unchanged(true, previous);
assert_eq!(d.change(true), Some(Outgoing::Dirty(true)));
let (message, previous) = d.prepare(true);
assert!(message.is_some());
let (later, _) = d.prepare(false);
assert_eq!(later, Some(Outgoing::Dirty(false)));
d.restore_if_unchanged(true, previous);
assert_eq!(d.change(false), None);
}
/// A canvas event is named for its type; the detail does not have to repeat it.
#[test]
fn a_page_event_is_named_for_its_type() {
assert_eq!(incoming_kind(Some("lightcraft:open"), None).as_deref(), Some("lightcraft:open"));
assert_eq!(incoming_kind(Some("lightcraft:cmd"), Some("lightcraft:open".into())).as_deref(), Some("lightcraft:cmd"));
assert_eq!(incoming_kind(None, Some("lightcraft:open".into())).as_deref(), Some("lightcraft:open"));
assert_eq!(incoming_kind(None, None), None);
}
/// A sandboxed iframe or a `data:` page has the opaque origin `"null"`, which any other such
/// window shares: no bridge there, so the origin check keeps meaning something.
#[test]
@@ -407,4 +594,39 @@ mod tests {
assert_eq!(save_name("IMG_0001.jpg"), "IMG_0001.jpg");
assert_eq!(save_name("lightcraft-library-backup-2026-10-19.zip"), "lightcraft-library-backup-2026-10-19.zip");
}
/// Page mode is opt-in by the exact attribute value, parent mode by the query, and parent
/// mode wins when a page asks for both.
#[test]
fn page_mode_is_opt_in_and_parent_mode_wins_when_both_are_set() {
assert_eq!(transport_for(Some("page"), ""), Some(Mode::Page));
assert_eq!(transport_for(Some("page"), "?host=parent"), Some(Mode::Parent), "parent mode wins");
// only the exact value opts in; anything else falls back to the query, or to standalone
for not_page in ["Page", " page", "page ", "parent", "host"] {
assert_eq!(transport_for(Some(not_page), "?host=parent"), Some(Mode::Parent), "{not_page:?}");
assert_eq!(transport_for(Some(not_page), ""), None, "{not_page:?}");
}
assert_eq!(transport_for(None, ""), None);
assert_eq!(transport_for(None, "?store=idb"), None);
assert_eq!(transport_for(None, "?host="), None);
assert_eq!(transport_for(None, "?host=Parent"), None);
assert_eq!(transport_for(None, "?xhost=parent"), None);
}
/// The query is read the way the URL options always read it: the first `host` parameter wins,
/// and only a value of exactly `parent` counts.
#[test]
fn the_query_asks_for_the_parent_bridge() {
assert!(host_parent_requested("?host=parent"));
assert!(host_parent_requested("host=parent"));
assert!(host_parent_requested("?host=parent&store=idb"));
assert!(host_parent_requested("?store=idb&host=parent"));
assert!(!host_parent_requested(""));
assert!(!host_parent_requested("?"));
assert!(!host_parent_requested("?host"));
assert!(!host_parent_requested("?host="));
assert!(host_parent_requested("?host=parent&host=id"), "the first host parameter wins");
assert!(!host_parent_requested("?host=id&host=parent"));
assert!(!host_parent_requested("?hosts=parent"));
}
}
+3 -1
View File
@@ -15,7 +15,9 @@
//! the library in browser storage); `?store=idb` forces the IndexedDB backend, `?store=memory` disables
//! persistence and `?workers=0` renders on the main thread.
//! `?host=parent` speaks to a same-origin page framing LightCraft (see [`host`]): it hands
//! photos in, receives what would be downloaded and hears about unsaved changes.
//! photos in, receives what would be downloaded and hears about unsaved changes. A page can also
//! host LightCraft in its own document, no iframe: `data-host="page"` on the canvas opts in, and
//! the same messages travel as DOM `CustomEvent`s on the canvas.
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+71 -13
View File
@@ -2,7 +2,7 @@
//! `<canvas id="lightcraft_canvas">`, wires the file picker and drag-and-drop, and turns exports
//! into downloads.
use std::cell::Cell;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::Arc;
@@ -76,8 +76,8 @@ struct Options {
store: String,
workers: Option<usize>,
reset: bool,
/// `host=parent`: a same-origin page frames LightCraft and speaks to it (see [`crate::host`]).
host: bool,
/// The raw query string, for the host mode ([`crate::host::transport_for`]).
query: String,
}
impl Options {
@@ -101,7 +101,7 @@ impl Options {
store: if bench { "memory".into() } else { get("store").filter(|s| s == "idb" || s == "memory").unwrap_or_else(|| "opfs".into()) },
workers: get("workers").and_then(|v| v.parse().ok()).map(|n: usize| n.min(crate::workers::MAX_WORKERS)),
reset: get("reset").is_some(),
host: get("host").as_deref() == Some("parent"),
query: q,
}
}
}
@@ -175,6 +175,57 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(())
}
/// Keep egui's hidden text field content-box, so host page CSS cannot misplace typed text:
/// eframe (0.36 web) types through a hidden `<input type="text">` it inserts as the canvas's
/// next sibling, and a host page's global `* { box-sizing: border-box }` reaches it and leaves it
/// no content box, so the next key lands before text just committed by an input method or a
/// paste-like insert. Inline style beats any host stylesheet rule without `!important`, so the
/// property is set on the element itself, in every host mode, and retried on the next animation
/// frames while eframe hasn't inserted the field yet.
fn fix_text_field_box_sizing() {
/// How many animation frames the fix may wait for eframe to insert its hidden text field.
const MAX_FRAMES: u32 = 120;
/// True when the field was found and set: the canvas's next sibling, or an
/// `input[type=text]` in the canvas's parent.
fn fix_once() -> bool {
let Some(doc) = window().and_then(|w| w.document()) else { return false };
let Some(canvas) = doc.get_element_by_id("lightcraft_canvas") else { return false };
let input = canvas.next_element_sibling().and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok()).or_else(|| {
canvas
.parent_element()
.and_then(|p| p.query_selector("input[type=text]").ok())
.flatten()
.and_then(|e| e.dyn_into::<web_sys::HtmlInputElement>().ok())
});
input.is_some_and(|i| i.style().set_property("box-sizing", "content-box").is_ok())
}
// the closure reschedules itself until the field is fixed or the frames run out; done, it is
// left parked in its slot (dropping it from inside itself would free its own state, and the
// slot holds the only reference) — a one-off leak, like the forgotten listeners
let tries = Rc::new(Cell::new(MAX_FRAMES));
let slot: Rc<RefCell<Option<Closure<dyn FnMut()>>>> = Rc::default();
let own = Rc::clone(&slot);
*slot.borrow_mut() = Some(Closure::new(move || {
if fix_once() || tries.get() <= 1 {
return; // done: stop rescheduling
}
tries.set(tries.get() - 1);
let again = own.borrow_mut().take();
if let Some(again) = again {
let _ = window().and_then(|w| w.request_animation_frame(again.as_ref().unchecked_ref()).ok());
// put the same closure back, now that the frame it was handed to has its own reference
*own.borrow_mut() = Some(again);
}
}));
let first = slot.borrow_mut().take();
if let Some(first) = first {
let _ = window().and_then(|w| w.request_animation_frame(first.as_ref().unchecked_ref()).ok());
*slot.borrow_mut() = Some(first);
}
}
/// Build the browser host's `Services`: library backup/restore through browser storage and a
/// download, asynchronous file picking, downloads for writes, and PNG encoding. Desktop-only
/// services (native pickers, reveal, model folders) stay unset.
@@ -918,10 +969,19 @@ pub fn start(chinese_font: Vec<u8>) {
}
let opts = Options::from_url();
// `?host=parent`: a same-origin page framing LightCraft hands photos in, stores what would
// be downloaded and hears about unsaved changes (see `host`)
let bridge = opts.host.then(crate::host::Bridge::new).flatten();
if opts.host && bridge.is_none() {
log::warn!("lightcraft: ?host=parent, but this page is not framed or has an opaque origin; no page to speak to");
// be downloaded and hears about unsaved changes (see `host`). `data-host="page"` on the
// canvas opts a page in instead: it runs LightCraft in its own document and talks over DOM
// `CustomEvent`s on the canvas. `?host=parent` wins when a page asks for both.
let canvas = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok());
let mode = crate::host::transport_for(canvas.as_ref().and_then(|c| c.get_attribute("data-host")).as_deref(), &opts.query);
let bridge = mode.and_then(|m| crate::host::Bridge::for_mode(m, canvas.as_ref()));
if mode == Some(crate::host::Mode::Parent) && bridge.is_none() {
log::warn!(
"lightcraft: ?host=parent, but this page is not framed, has an opaque origin, or its parent is on another origin; exports keep downloading"
);
}
wasm_bindgen_futures::spawn_local(async move {
// one tab per library: two would each keep their own copy and overwrite each other's saves
@@ -935,11 +995,7 @@ pub fn start(chinese_font: Vec<u8>) {
}
return;
}
let Some(canvas) = window()
.and_then(|w| w.document())
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
let Some(canvas) = canvas else {
log::error!("missing <canvas id=\"lightcraft_canvas\">");
if let Some(b) = &bridge {
b.post(&Outgoing::Failed("the page has no <canvas id=\"lightcraft_canvas\">".into()));
@@ -954,6 +1010,8 @@ pub fn start(chinese_font: Vec<u8>) {
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
el.remove();
}
// the runner just started: egui's hidden text field exists from now on
fix_text_field_box_sizing();
}
Err(e) => {
log::error!("LightCraft failed to start: {e:?}");
+10
View File
@@ -178,6 +178,16 @@ are the same in every version, so serve them with `Cache-Control: no-cache` (not
answered. A page with an opaque origin (`"null"`: a sandboxed iframe, a `data:` page), or a
parent on another origin, gets no bridge, and exports keep downloading. The library still lives
in the iframe's browser storage.
- **Embedding in the page's own document (`data-host="page"`).** A page that loads LightCraft into its own
document, with no iframe, puts `data-host="page"` on the app's canvas. The same messages then travel as
non-bubbling `CustomEvent`s on the canvas: LightCraft dispatches `lightcraft:ready`, `lightcraft:failed`,
`lightcraft:dirty`, `lightcraft:save` and `lightcraft:response` with the message's fields as `detail`, and the page
dispatches `lightcraft:open` and `lightcraft:cmd` on the canvas (`detail` is the fields; the event name is the
type). Add the listeners before the module starts, because `lightcraft:ready` is sent as soon as the app listens.
Only the exact value `page` turns it on. When the page asks for both, `?host=parent` wins. In this mode the page
and LightCraft share one document, so any script on the page can drive the app and receive what it saves: use it
only on a page you control. A photo handed in is held to the same size limit as in the iframe mode and refused
before its bytes are copied.
- **The page title names the open photo**: `<file name> — LightCraft`, with ` •` after the name while
changes are not yet written to browser storage (`LightcraftApp::page_title`). eframe's web runner
ignores the window title the desktop app sets, and a host that embeds the page reads this title to
+3
View File
@@ -7,6 +7,9 @@
protocol PdfCraft, GridCraft and DeckCraft share: it hands photos in, receives exports and backups instead of
downloads, runs commands and hears whether closing would lose unsaved changes (issue #2270; `docs/web.md`).
A parent on another origin, or a page with an opaque origin, keeps the normal downloads.
- A page can also run LightCraft in its own document, with no iframe: `data-host="page"` on the canvas carries the same
messages as `CustomEvent`s on the canvas instead of `postMessage` (issue #2270; `docs/web.md`). `?host=parent` wins
when a page asks for both.
### Remove Hot Pixels
- Detail ▸ Noise Reduction has a new box, Remove Hot Pixels (raws only), on for newly imported raws (issue #1345).