mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-13 01:14:21 -06:00
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:
@@ -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
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user