From 35ce39634b4559d8b5a4d56494f77cd471f190d0 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 11 Jul 2026 03:58:16 +0800 Subject: [PATCH] fix(canvas): preview renders design-canvas geometry with pointer capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preview scenes now take paint from the promoted document but GEOMETRY from the unpromoted layout tree via the design canvas's exact layout pass — honoring preserve_authored_geometry for Figma imports — so preview positions match design mode by construction (was: full taffy re-solve, elements shifted 100+ px on preserve docs, 2 px per promoted hug widget). Hit-testing maps taps through the deepest painted node's scene/runtime rect pair with a per-gesture anchor (pointer capture), so drags never remap through neighbours mid-gesture. Input dispatch split to preview/input.rs for the 800-line cap. Pairs with the following device-frame commit (preview/mod.rs already declares the present module it introduces). --- crates/op-host-native/src/preview/input.rs | 312 ++++++++++++++++++ crates/op-host-native/src/preview/mod.rs | 251 ++++---------- crates/op-host-native/src/preview/tests.rs | 56 ++-- .../src/preview/tests_bindings.rs | 14 +- .../src/preview/tests_geometry_parity.rs | 263 +++++++++++++++ crates/op-host-native/tests/preview_raster.rs | 4 +- crates/op-pen-loader/src/adapter.rs | 73 ++++ crates/op-pen-loader/src/layout_scene.rs | 45 +++ crates/op-pen-loader/src/lib.rs | 5 +- packages/op-web-sdk-react/package.json | 41 ++- packages/op-web-sdk-vue/package.json | 38 ++- packages/op-web-sdk/package.json | 20 +- 12 files changed, 885 insertions(+), 237 deletions(-) create mode 100644 crates/op-host-native/src/preview/input.rs create mode 100644 crates/op-host-native/src/preview/tests_geometry_parity.rs diff --git a/crates/op-host-native/src/preview/input.rs b/crates/op-host-native/src/preview/input.rs new file mode 100644 index 000000000..7f27109b3 --- /dev/null +++ b/crates/op-host-native/src/preview/input.rs @@ -0,0 +1,312 @@ +//! Input dispatch for [`super::PreviewSession`] — keyboard, focus, +//! and the pointer pipeline with its scene→runtime coordinate mapping. +//! +//! Split out of `preview/mod.rs` to honor the repo's 800-line-per-file +//! cap (same pattern as `app_mode.rs` / `scene_helpers.rs`: an inherent +//! `impl` block in a child module reaching the session's plain-private +//! fields, which Rust's default privacy already exposes to descendant +//! modules). +//! +//! ## Scene→runtime mapping + pointer capture +//! +//! The scene paints DESIGN-canvas geometry (authored rects for Figma +//! Preserve imports; the unpromoted flex solve otherwise), while the +//! runtime hit-tests its OWN layout (the promoted tree, always +//! flex-solved). The two can disagree per node, so a tap maps through +//! the pair of rects of the deepest painted node it hit. A pointer +//! gesture anchors that pair at `Down` and reuses it for every held +//! `Move` and the `Up` (pointer capture), so a drag never remaps +//! through a neighbour mid-gesture. + +use super::PreviewSession; + +use jian_core::gesture::pointer::{Modifiers, PointerPhase}; +use op_editor_ui::layout_scene::SceneNode; +use op_editor_ui::{Point2D, Rect}; + +impl PreviewSession { + /// Route a printable character into the focused widget. Returns + /// `true` when the runtime consumed it (a focused editable widget + /// accepted the text). + pub fn dispatch_text(&mut self, text: &str) -> bool { + self.runtime.dispatch_text_input(text) + } + + /// Route a named key (e.g. `"Backspace"`, `"ArrowLeft"`, `"Enter"`, + /// `"Tab"`) into the runtime with the given modifier set. Returns + /// `true` when the dispatch emitted any semantic event. + pub fn dispatch_key(&mut self, key: &str, modifiers: Modifiers) -> bool { + !self + .runtime + .dispatch_keyboard(key.to_string(), modifiers) + .is_empty() + } + + /// Dispatch a tap (Down then Up) at a SCENE-space point into the + /// runtime so clicks land on switches / buttons / and place caret / + /// focus in text inputs. The host converts the screen press to scene + /// (document) space via the editor viewport; here we translate it + /// into the runtime's root-relative space (subtract the containing + /// root's authored origin) so the hit-test matches where the widget + /// paints. Returns `true` when the runtime emitted any semantic + /// event. + pub fn dispatch_tap(&mut self, scene_x: f32, scene_y: f32) -> bool { + let down = self.dispatch_pointer_phase(scene_x, scene_y, PointerPhase::Down); + let up = self.dispatch_pointer_phase(scene_x, scene_y, PointerPhase::Up); + down || up + } + + /// Dispatch one pointer phase at a SCENE-space point. `Down`/`Up`/ + /// `Move` carry mouse-left button semantics (a held drag — slider + /// knobs); `Hover` is an unpressed move (fires `onHoverEnter` / + /// `onHoverLeave` actions). Returns `true` when the runtime emitted + /// any semantic event. + /// + /// Pointer-capture semantics: `Down` anchors the scene→runtime + /// mapping on the node it hit, and the held `Move`s + the `Up` + /// reuse THAT anchor. Re-resolving per event would remap a drag + /// through whatever node the pointer crosses (a slider drag past + /// its own edge would jump into a neighbour's coordinate space and + /// could activate a widget the pointer isn't visually over). + pub fn dispatch_pointer_phase( + &mut self, + scene_x: f32, + scene_y: f32, + phase: PointerPhase, + ) -> bool { + use jian_core::geometry::point; + use jian_core::gesture::pointer::{MouseButtons, PointerEvent, PointerKind}; + let (rt_x, rt_y) = self.resolve_runtime_point(scene_x, scene_y, phase); + let mut ev = PointerEvent::simple(1, phase, point(rt_x, rt_y)); + ev.kind = PointerKind::Mouse; + if matches!(phase, PointerPhase::Hover) { + ev.buttons = MouseButtons::empty(); + ev.pressure = 0.0; + } + !self.runtime.dispatch_pointer(ev).is_empty() + } + + /// The scene→runtime point for one pointer phase, honoring the + /// gesture anchor: `Down` resolves fresh and stores the mapping, + /// pressed `Move` reuses it, `Up` reuses then clears it, `Hover` + /// (unpressed) always resolves fresh and never stores. + fn resolve_runtime_point(&mut self, x: f32, y: f32, phase: PointerPhase) -> (f32, f32) { + let anchored = |session: &Self, mapping: Option<(Rect, Rect)>| match mapping { + Some(m) => session.scene_to_runtime_via(x, y, Some(m)), + // No anchor (the Down hit no mapped node, or a stray Move + // without a Down): resolve fresh at the point. + None => session.scene_to_runtime(x, y), + }; + match phase { + PointerPhase::Down => { + self.gesture_mapping = self.deepest_mapped_rects(x, y); + anchored(self, self.gesture_mapping) + } + PointerPhase::Move => anchored(self, self.gesture_mapping), + PointerPhase::Up | PointerPhase::Cancel => { + let mapping = self.gesture_mapping.take(); + anchored(self, mapping) + } + PointerPhase::Hover => self.scene_to_runtime(x, y), + } + } + + /// Route a wheel at a SCENE-space point into the runtime. Returns + /// `true` only when a node carrying `events.onScroll` consumed it — + /// the host falls back to canvas pan/zoom otherwise. `dx`/`dy` are + /// screen-pixel deltas (same magnitude the design canvas pans by). + pub fn dispatch_wheel(&mut self, scene_x: f32, scene_y: f32, dx: f32, dy: f32) -> bool { + use jian_core::geometry::point; + use jian_core::gesture::pointer::WheelEvent; + let (rt_x, rt_y) = self.scene_to_runtime(scene_x, scene_y); + let ev = WheelEvent::simple(point(rt_x, rt_y), point(dx, dy)); + !self.runtime.dispatch_wheel(ev).is_empty() + } + + /// Translate a scene-space point into the runtime's hit-test space. + /// + /// The scene paints DESIGN-canvas geometry (authored rects for + /// Figma Preserve imports; the unpromoted flex solve otherwise), + /// while the runtime hit-tests its OWN layout (the promoted tree, + /// always flex-solved). The two can disagree per node, so a plain + /// root-origin subtraction would land taps on the wrong element. + /// Instead: find the deepest painted node containing the point that + /// also exists in the runtime layout, and map the point through the + /// pair of rects (offset + proportional scale), so a tap lands at + /// the same relative spot inside the runtime's copy of the node — + /// keeping caret placement and slider-knob drags accurate. + /// + /// Falls back to the root-origin translation when the point is + /// outside every mapped node (empty canvas — nothing to hit). + fn scene_to_runtime(&self, x: f32, y: f32) -> (f32, f32) { + let mapping = self.deepest_mapped_rects(x, y); + self.scene_to_runtime_via(x, y, mapping) + } + + /// Map a scene point through a given (scene rect, runtime rect) + /// anchor — same relative position inside the runtime rect, + /// linearly extrapolated when the point is outside the scene rect + /// (a held drag past the node's edge). `None` resolves fresh at the + /// point, falling back to the root-origin translation. + fn scene_to_runtime_via(&self, x: f32, y: f32, mapping: Option<(Rect, Rect)>) -> (f32, f32) { + if let Some((s, r)) = mapping { + let fx = if s.size.x > f32::EPSILON { + (x - s.origin.x) / s.size.x + } else { + 0.0 + }; + let fy = if s.size.y > f32::EPSILON { + (y - s.origin.y) / s.size.y + } else { + 0.0 + }; + return (r.origin.x + fx * r.size.x, r.origin.y + fy * r.size.y); + } + for frame in &self.root_frames { + let rect = frame.scene_rect; + if x >= rect.origin.x + && x <= rect.origin.x + rect.size.x + && y >= rect.origin.y + && y <= rect.origin.y + rect.size.y + { + return (x - frame.offset.0, y - frame.offset.1); + } + } + (x, y) + } + + /// The (scene rect, runtime rect) pair of the deepest visible scene + /// node containing the point that also has a runtime layout rect. + /// Children win over parents; later siblings (painted on top) win + /// over earlier ones. + fn deepest_mapped_rects(&self, x: f32, y: f32) -> Option<(Rect, Rect)> { + let page = self.scene.active_page()?; + for node in page.children.iter().rev() { + if let Some(hit) = self.deepest_mapped_in(node, x, y) { + return Some(hit); + } + } + None + } + + fn deepest_mapped_in(&self, node: &SceneNode, x: f32, y: f32) -> Option<(Rect, Rect)> { + if node.hidden { + return None; + } + let b = node.bounds; + if x < b.origin.x + || x > b.origin.x + b.size.x + || y < b.origin.y + || y > b.origin.y + b.size.y + { + return None; + } + for child in node.children.iter().rev() { + if let Some(hit) = self.deepest_mapped_in(child, x, y) { + return Some(hit); + } + } + self.runtime_rect(&node.id).map(|r| (b, r)) + } + + /// The runtime layout rect for the node with schema `id`, in the + /// runtime's hit-test space, or `None` when the id has no live + /// runtime node (e.g. a child a promotion dropped from the tree). + /// `pub(in crate::preview)` so `mod.rs`'s test-only `node_rect` + /// accessor can reach it from the parent module. + pub(in crate::preview) fn runtime_rect(&self, id: &str) -> Option { + let doc = self.runtime.document.as_ref()?; + let key = doc.tree.by_id.get(id).copied()?; + let r = self.runtime.layout.node_rect(key)?; + Some(Rect { + origin: Point2D::new(r.origin.x, r.origin.y), + size: Point2D::new(r.size.width, r.size.height), + }) + } + + /// Advance focus to the next focusable widget (Tab). + pub fn focus_next(&mut self) { + self.runtime.focus_next(); + self.seed_focused_widget_state(); + } + + /// Advance focus to the previous focusable widget (Shift+Tab). + pub fn focus_previous(&mut self) { + self.runtime.focus_previous(); + self.seed_focused_widget_state(); + } + + /// Lazily seed the focused widget's runtime state so a freshly + /// Tab-focused (but not-yet-typed) text input shows its caret right + /// away — `Runtime::focus_next` only moves the focus pointer; it + /// does not touch the widget-state store. A no-op for non-widget + /// (or already-seeded) focus targets. + fn seed_focused_widget_state(&mut self) { + let Some(key) = self.runtime.focus.current() else { + return; + }; + // Clone the focused node's schema so the `&PenNode` borrow of + // `runtime.document` is released before `get_or_init` takes + // `runtime.widget_states` mutably (focus changes are rare, so + // the clone is cheap relative to the interaction it serves). + let schema = self + .runtime + .document + .as_ref() + .and_then(|d| d.tree.nodes.get(key)) + .map(|n| n.schema.clone()); + if let Some(schema) = schema { + self.runtime + .widget_states + .get_or_init(&schema, &self.runtime.state); + } + } + + /// Test-only: translate a scene-space point into the runtime's + /// root-relative space (exercises the tap coordinate fix). + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn scene_to_runtime_for_test(&self, x: f32, y: f32) -> (f32, f32) { + self.scene_to_runtime(x, y) + } + + /// Test-only: the phase-aware point resolution `dispatch_pointer_phase` + /// uses (exercises the gesture-anchored pointer-capture mapping). + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn resolve_runtime_point_for_test( + &mut self, + x: f32, + y: f32, + phase: PointerPhase, + ) -> (f32, f32) { + self.resolve_runtime_point(x, y, phase) + } + + /// Test-only: install a gesture anchor directly, simulating a Down + /// on a node whose scene and runtime rects diverge (promotion hug + /// drift, engine drift) without depending on a fixture that + /// reproduces the divergence organically. + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn set_gesture_mapping_for_test(&mut self, scene: Rect, runtime: Rect) { + self.gesture_mapping = Some((scene, runtime)); + } + + /// Test-only: focus a node by schema `id` directly (skips the + /// Tab-ring walk `focus_next`/`focus_previous` use), then seed its + /// widget runtime state the same way those two do. Returns `true` + /// when the id resolved to a live node AND that node is in the + /// focus chain (`FocusManager::request` rejects ids outside it). + #[cfg(all(test, not(target_os = "windows")))] + pub(crate) fn focus_node_for_test(&mut self, id: &str) -> bool { + let Some(key) = self + .runtime + .document + .as_ref() + .and_then(|d| d.tree.by_id.get(id).copied()) + else { + return false; + }; + self.runtime.focus_request(key); + self.seed_focused_widget_state(); + self.runtime.focus.current() == Some(key) + } +} diff --git a/crates/op-host-native/src/preview/mod.rs b/crates/op-host-native/src/preview/mod.rs index 70b7578fc..9f877efc0 100644 --- a/crates/op-host-native/src/preview/mod.rs +++ b/crates/op-host-native/src/preview/mod.rs @@ -38,26 +38,29 @@ //! //! ## Hit-testing across two coordinate spaces //! -//! The design scene offsets every page-root by its authored -//! `(base.x, base.y)`, but the jian runtime lays each root at its own -//! `(0, 0)`. So a tap arrives in SCENE space (it inverts the scene paint -//! transform) and must be translated back into the runtime's -//! root-relative space — subtract the containing root's authored origin -//! — before [`Runtime::dispatch_pointer`]. See [`PreviewSession::dispatch_tap`]. +//! The scene paints DESIGN-canvas geometry while the runtime hit-tests +//! its own (promoted, re-solved) layout, so a tap arriving in SCENE +//! space maps through the rect pair of the deepest painted node it hit, +//! with a per-gesture anchor (pointer capture). The whole pipeline +//! lives in `input.rs` — see its module docs. //! //! ## Module split //! //! To honor the 800-line-per-file cap, [`AppMode`] + the per-root //! `solve_roots` + the app-mode query methods live in `app_mode.rs`, -//! and the leaf formatter helpers (`apply_widget_state` / -//! `display_string` / `format_warning`) live in `scene_helpers.rs`. -//! `RootFrame` + `PreviewSession` stay here (shared), with the fields -//! those sibling modules touch scoped `pub(in crate::preview)` (not -//! `pub(super)`, which resolves to `pub(crate)` at this top-level -//! module and would trip `private_interfaces` on the `AppMode` type). +//! keyboard/focus/pointer dispatch + the scene→runtime coordinate +//! mapping live in `input.rs`, and the leaf formatter helpers +//! (`apply_widget_state` / `display_string` / `format_warning`) live in +//! `scene_helpers.rs`. `RootFrame` + `PreviewSession` stay here +//! (shared), with the fields those sibling modules touch scoped +//! `pub(in crate::preview)` (not `pub(super)`, which resolves to +//! `pub(crate)` at this top-level module and would trip +//! `private_interfaces` on the `AppMode` type). mod app_mode; mod binding_sites; +mod input; +mod present; mod scene_helpers; // Gated off Windows: preview tests exercise runtime layout through // `jian_skia::SkiaMeasure`, which hits DirectWrite in Windows CI and aborts @@ -69,12 +72,18 @@ mod tests; mod tests_app_mode; #[cfg(all(test, not(target_os = "windows")))] mod tests_bindings; +#[cfg(all(test, not(target_os = "windows")))] +mod tests_device_frame; +#[cfg(all(test, not(target_os = "windows")))] +mod tests_geometry_parity; use app_mode::AppMode; use binding_sites::{collect_binding_sites, BindingSite}; use scene_helpers::{apply_widget_state, display_string, format_warning}; -use jian_core::gesture::pointer::{Modifiers, PointerPhase}; +#[allow(unused_imports)] +pub(crate) use present::PinnedPaint; + use jian_core::widget_state::WidgetState; use jian_core::Runtime; use jian_ops_schema::compat::{load_str_with, LoadOptions}; @@ -116,12 +125,23 @@ pub struct PreviewSession { /// `pub(in crate::preview)` so `app_mode`'s /// `current_screen_scene_rect` can read the first frame's scene rect. pub(in crate::preview) root_frames: Vec, - /// The design `LayoutScene` preview paints, built from the SAME - /// prepared + PROMOTED document the runtime was seeded from (so a - /// generated/legacy `role=input` field renders as an interactive - /// `text_input` widget, not a frame). Live widget values are + /// The design `LayoutScene` preview paints: paint tree from the + /// prepared + PROMOTED document (so a generated/legacy `role=input` + /// field renders as an interactive `text_input` widget, not a + /// frame), GEOMETRY from the unpromoted `layout_doc` laid out + /// exactly as the design canvas lays it out — so node positions + /// match design mode by construction. Live widget values are /// overlaid onto a clone of this each frame in `paint_scene`. scene: LayoutScene, + /// The prepared (ref/token-resolved, page-projected / screen- + /// normalized) but UNPROMOTED document — the geometry source the + /// design canvas would lay out. Kept so app-mode screen switches + /// rebuild the scene against the same geometry. + layout_doc: jian_ops_schema::PenDocument, + /// Whether the editor document carries authored (Figma Preserve) + /// geometry: the design canvas skips the flex solver for these, so + /// preview must too or every element shifts. + preserve_authored_geometry: bool, /// Non-fatal load warnings (e.g. legacy role promotions), formatted /// for display in the editor's `preview_warnings`. warnings: Vec, @@ -133,6 +153,12 @@ pub struct PreviewSession { /// classic single-page workbench preview. `pub(in crate::preview)` /// so `app_mode`'s `is_app_mode` can read it. See [`AppMode`]. pub(in crate::preview) app: Option, + /// The (scene rect, runtime rect) pair the current pointer gesture + /// anchored on at `Down` — held `Move`s and the `Up` map through + /// it (pointer capture), so a drag that leaves the node's scene + /// bounds doesn't remap through a neighbour. `None` between + /// gestures or when the `Down` hit no mapped node. + gesture_mapping: Option<(Rect, Rect)>, } impl PreviewSession { @@ -177,6 +203,7 @@ impl PreviewSession { canvas_size: (f32, f32), active_theme: &std::collections::BTreeMap, active_page_index: usize, + preserve_authored_geometry: bool, ) -> Result { let _ = canvas_size; // layout is root-derived, not canvas-derived. @@ -245,8 +272,13 @@ impl PreviewSession { prepared = std::borrow::Cow::Owned(owned); } + // Own the prepared (unpromoted) tree: it is BOTH the runtime's + // serialization source and the preview scene's geometry source + // (the design canvas lays out this exact tree, so taking rects + // from it keeps preview positions design-identical). + let layout_doc = prepared.into_owned(); let src = - serde_json::to_string(&*prepared).map_err(|e| format!("serialize document: {e}"))?; + serde_json::to_string(&layout_doc).map_err(|e| format!("serialize document: {e}"))?; let loaded = load_str_with( &src, @@ -313,23 +345,32 @@ impl PreviewSession { let (root_frames, primary_available) = app_mode::solve_roots(&mut runtime)?; - // Build the design scene from the promoted document. The active - // page was projected to the top-level `children` in `enter`, so - // it is page index 0. Refs/tokens are already resolved in - // `promoted_doc`, so the builder's detector walks early-out. - // APP MODE: page 0 of `promoted_doc` is the entry screen (the - // same convention `project_screens` guarantees), so this stays - // page-index 0 either way. - let scene = op_pen_loader::pen_document_to_layout_scene(&promoted_doc, active_theme, 0); + // Build the preview scene: paint tree from the promoted + // document, geometry from the unpromoted `layout_doc` — the + // design canvas's exact layout (or, for Figma Preserve imports, + // its authored rects), so preview positions match design mode + // by construction. The active page was projected to the top + // level in `enter`, so it is page index 0. APP MODE: page 0 is + // the entry screen (the `project_screens` convention) either way. + let scene = op_pen_loader::pen_document_to_layout_scene_for_preview( + &promoted_doc, + &layout_doc, + preserve_authored_geometry, + active_theme, + 0, + ); Ok(Self { runtime, available: primary_available, root_frames, scene, + layout_doc, + preserve_authored_geometry, warnings, binding_sites, app, + gesture_mapping: None, }) } @@ -547,131 +588,6 @@ impl PreviewSession { Some(jian_core::document::tree::node_schema_id(&node.schema).to_owned()) } - // --- Input dispatch ------------------------------------------- - - /// Route a printable character into the focused widget. Returns - /// `true` when the runtime consumed it (a focused editable widget - /// accepted the text). - pub fn dispatch_text(&mut self, text: &str) -> bool { - self.runtime.dispatch_text_input(text) - } - - /// Route a named key (e.g. `"Backspace"`, `"ArrowLeft"`, `"Enter"`, - /// `"Tab"`) into the runtime with the given modifier set. Returns - /// `true` when the dispatch emitted any semantic event. - pub fn dispatch_key(&mut self, key: &str, modifiers: Modifiers) -> bool { - !self - .runtime - .dispatch_keyboard(key.to_string(), modifiers) - .is_empty() - } - - /// Dispatch a tap (Down then Up) at a SCENE-space point into the - /// runtime so clicks land on switches / buttons / and place caret / - /// focus in text inputs. The host converts the screen press to scene - /// (document) space via the editor viewport; here we translate it - /// into the runtime's root-relative space (subtract the containing - /// root's authored origin) so the hit-test matches where the widget - /// paints. Returns `true` when the runtime emitted any semantic - /// event. - pub fn dispatch_tap(&mut self, scene_x: f32, scene_y: f32) -> bool { - let down = self.dispatch_pointer_phase(scene_x, scene_y, PointerPhase::Down); - let up = self.dispatch_pointer_phase(scene_x, scene_y, PointerPhase::Up); - down || up - } - - /// Dispatch one pointer phase at a SCENE-space point. `Down`/`Up`/ - /// `Move` carry mouse-left button semantics (a held drag — slider - /// knobs); `Hover` is an unpressed move (fires `onHoverEnter` / - /// `onHoverLeave` actions). Returns `true` when the runtime emitted - /// any semantic event. - pub fn dispatch_pointer_phase( - &mut self, - scene_x: f32, - scene_y: f32, - phase: PointerPhase, - ) -> bool { - use jian_core::geometry::point; - use jian_core::gesture::pointer::{MouseButtons, PointerEvent, PointerKind}; - let (rt_x, rt_y) = self.scene_to_runtime(scene_x, scene_y); - let mut ev = PointerEvent::simple(1, phase, point(rt_x, rt_y)); - ev.kind = PointerKind::Mouse; - if matches!(phase, PointerPhase::Hover) { - ev.buttons = MouseButtons::empty(); - ev.pressure = 0.0; - } - !self.runtime.dispatch_pointer(ev).is_empty() - } - - /// Route a wheel at a SCENE-space point into the runtime. Returns - /// `true` only when a node carrying `events.onScroll` consumed it — - /// the host falls back to canvas pan/zoom otherwise. `dx`/`dy` are - /// screen-pixel deltas (same magnitude the design canvas pans by). - pub fn dispatch_wheel(&mut self, scene_x: f32, scene_y: f32, dx: f32, dy: f32) -> bool { - use jian_core::geometry::point; - use jian_core::gesture::pointer::WheelEvent; - let (rt_x, rt_y) = self.scene_to_runtime(scene_x, scene_y); - let ev = WheelEvent::simple(point(rt_x, rt_y), point(dx, dy)); - !self.runtime.dispatch_wheel(ev).is_empty() - } - - /// Translate a scene-space point into the runtime's root-relative - /// hit-test space: find the page-root whose scene bounds contain the - /// point and subtract its authored origin. Falls through unchanged - /// when the point is outside every root (nothing to hit there). For - /// a single root authored at the origin this is the identity. - fn scene_to_runtime(&self, x: f32, y: f32) -> (f32, f32) { - for frame in &self.root_frames { - let r = frame.scene_rect; - if x >= r.origin.x - && x <= r.origin.x + r.size.x - && y >= r.origin.y - && y <= r.origin.y + r.size.y - { - return (x - frame.offset.0, y - frame.offset.1); - } - } - (x, y) - } - - /// Advance focus to the next focusable widget (Tab). - pub fn focus_next(&mut self) { - self.runtime.focus_next(); - self.seed_focused_widget_state(); - } - - /// Advance focus to the previous focusable widget (Shift+Tab). - pub fn focus_previous(&mut self) { - self.runtime.focus_previous(); - self.seed_focused_widget_state(); - } - - /// Lazily seed the focused widget's runtime state so a freshly - /// Tab-focused (but not-yet-typed) text input shows its caret right - /// away — `Runtime::focus_next` only moves the focus pointer; it - /// does not touch the widget-state store. A no-op for non-widget - /// (or already-seeded) focus targets. - fn seed_focused_widget_state(&mut self) { - let Some(key) = self.runtime.focus.current() else { - return; - }; - // Clone the focused node's schema so the `&PenNode` borrow of - // `runtime.document` is released before `get_or_init` takes - // `runtime.widget_states` mutably (focus changes are rare, so - // the clone is cheap relative to the interaction it serves). - let schema = self - .runtime - .document - .as_ref() - .and_then(|d| d.tree.nodes.get(key)) - .map(|n| n.schema.clone()); - if let Some(schema) = schema { - self.runtime - .widget_states - .get_or_init(&schema, &self.runtime.state); - } - } - /// Test-only read access to the live runtime so the host test can /// assert injected text reached the widget state graph. #[cfg(all(test, not(target_os = "windows")))] @@ -687,22 +603,13 @@ impl PreviewSession { self.overlay_runtime_state(&self.scene) } - /// Test-only: translate a scene-space point into the runtime's - /// root-relative space (exercises the tap coordinate fix). - #[cfg(all(test, not(target_os = "windows")))] - pub(crate) fn scene_to_runtime_for_test(&self, x: f32, y: f32) -> (f32, f32) { - self.scene_to_runtime(x, y) - } - /// Test-only: the absolute layout rect `(x, y, w, h)` the runtime /// resolved for the node with schema `id`, or `None` if unknown. /// In the runtime's root-relative space (no scene offset). #[cfg(all(test, not(target_os = "windows")))] pub(crate) fn node_rect(&self, id: &str) -> Option<(f32, f32, f32, f32)> { - let doc = self.runtime.document.as_ref()?; - let key = doc.tree.by_id.get(id).copied()?; - let r = self.runtime.layout.node_rect(key)?; - Some((r.origin.x, r.origin.y, r.size.width, r.size.height)) + let r = self.runtime_rect(id)?; + Some((r.origin.x, r.origin.y, r.size.x, r.size.y)) } /// Test-only: the available size the runtime's primary root was laid @@ -726,26 +633,6 @@ impl PreviewSession { self.root_frames.len() } - /// Test-only: focus a node by schema `id` directly (skips the - /// Tab-ring walk `focus_next`/`focus_previous` use), then seed its - /// widget runtime state the same way those two do. Returns `true` - /// when the id resolved to a live node AND that node is in the - /// focus chain (`FocusManager::request` rejects ids outside it). - #[cfg(all(test, not(target_os = "windows")))] - pub(crate) fn focus_node_for_test(&mut self, id: &str) -> bool { - let Some(key) = self - .runtime - .document - .as_ref() - .and_then(|d| d.tree.by_id.get(id).copied()) - else { - return false; - }; - self.runtime.focus_request(key); - self.seed_focused_widget_state(); - self.runtime.focus.current() == Some(key) - } - /// Test-only: the current text value of the text-input-family /// widget with schema `id`, forcing the same lazy /// `WidgetStateStore::get_or_init` seed a live interaction would diff --git a/crates/op-host-native/src/preview/tests.rs b/crates/op-host-native/src/preview/tests.rs index 51e25c137..fcd4bdf5f 100644 --- a/crates/op-host-native/src/preview/tests.rs +++ b/crates/op-host-native/src/preview/tests.rs @@ -54,7 +54,7 @@ fn enter_input_exit_leaves_document_byte_identical() { let before = serde_json::to_string(&doc).expect("serialize before"); { - let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0) + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) .expect("enter preview"); session.set_now_ms(0); session.focus_next(); @@ -73,8 +73,8 @@ fn enter_input_exit_leaves_document_byte_identical() { fn dispatched_text_reaches_runtime_state_graph() { // Injected text must land in the runtime's widget state, not the doc. let doc = text_input_doc(); - let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); session.set_now_ms(0); session.focus_next(); let consumed = session.dispatch_text("hi"); @@ -97,8 +97,8 @@ fn overlay_reflects_typed_text() { // overlaid scene the painter walks must carry the typed text on the // field's `SceneWidget.value_str` (so `paint_text_field` draws it). let doc = text_input_doc(); - let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); session.set_now_ms(0); session.focus_next(); session.dispatch_text("hi"); @@ -142,8 +142,8 @@ fn preview_shows_resolved_color_in_scene() { let doc = jian_ops_schema::load_str(src) .expect("parse var-color doc") .value; - let session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); let scene = session.preview_scene_for_test(); let swatch = find(&scene, "swatch").expect("swatch node"); @@ -182,8 +182,8 @@ fn preview_shows_resolved_text_token() { let doc = jian_ops_schema::load_str(src) .expect("parse tokened-text doc") .value; - let session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); let scene = session.preview_scene_for_test(); let greet = find(&scene, "greet").expect("greet text node"); @@ -223,8 +223,8 @@ fn overlay_reflects_widget_toggle_on_tap() { // taps land where widgets paint AND the live state surfaces in the // render. let doc = switch_doc(); - let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); session.set_now_ms(0); // Switch starts unchecked. @@ -274,8 +274,8 @@ fn tap_translates_scene_space_to_runtime_for_offset_root() { let doc = jian_ops_schema::load_str(src) .expect("parse offset-root doc") .value; - let session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); // A point inside the root in SCENE space maps to that point minus // the root's authored origin in RUNTIME space. @@ -359,8 +359,8 @@ fn preview_layout_matches_design_canvas() { // A deliberately HUGE canvas region — the old code laid the doc out // against this and scattered. The fix ignores it (per-root). - let session = - PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0, false) + .expect("enter preview"); let (aw, _ah) = session.available(); assert!( @@ -444,8 +444,8 @@ fn preview_children_stay_within_root_width() { .map(|n| n.w) .expect("design root width"); - let session = - PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0, false) + .expect("enter preview"); const BLEED: f32 = 2.0; for id in ["screen", "cats", "cat-pizza", "cat-seeall"] { @@ -539,8 +539,8 @@ fn preview_lays_each_root_against_its_own_size() { "design rows should differ widely (a={design_row_a}, b={design_row_b}) — fixture sanity" ); - let session = - PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (1600.0, 1200.0), &default_theme(), 0, false) + .expect("enter preview"); const TOL: f32 = 1.0; let (_pax, _pay, pw_a, _pah) = session.node_rect("row-a").expect("preview rect for row-a"); @@ -573,7 +573,7 @@ fn legacy_role_promotion_is_recorded_as_warning() { .expect("parse legacy doc") .value; - let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0) + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) .expect("enter preview on legacy doc"); for w in session.warnings() { assert!(!w.is_empty()); @@ -586,8 +586,8 @@ fn focus_seeds_widget_state_for_caret() { // its runtime state so the caret can paint immediately — // `Runtime::focus_next` alone only moves the focus pointer. let doc = text_input_doc(); - let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); session.set_now_ms(0); session.focus_next(); assert!( @@ -616,8 +616,8 @@ fn preview_promotes_role_input_frame_to_interactive_field() { ) .expect("parse role=input doc") .value; - let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter preview"); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); session.set_now_ms(0); // The promoted field renders as a text_input widget in preview's scene. @@ -675,7 +675,7 @@ fn hover_doc() -> jian_ops_schema::PenDocument { fn hover_move_fires_on_hover_enter() { let doc = hover_doc(); let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); session.set_now_ms(0); session.dispatch_pointer_phase(20.0, 20.0, PointerPhase::Hover); let v = session @@ -709,7 +709,7 @@ fn slider_doc() -> jian_ops_schema::PenDocument { fn slider_drag_moves_value() { let doc = slider_doc(); let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); session.set_now_ms(0); let (x, y, w, h) = session.node_rect("vol").expect("slider rect"); let cy = y + h / 2.0; @@ -742,7 +742,7 @@ fn wheel_routes_only_to_on_scroll_handler() { }"##; let doc = jian_ops_schema::load_str(src).expect("parse").value; let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); assert!( session.dispatch_wheel(20.0, 20.0, 0.0, -12.0), "onScroll node must consume" @@ -750,7 +750,7 @@ fn wheel_routes_only_to_on_scroll_handler() { let plain = text_input_doc(); let mut plain_session = - PreviewSession::enter(&plain, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&plain, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); assert!( !plain_session.dispatch_wheel(20.0, 20.0, 0.0, -12.0), "no handler → not consumed → host may pan/zoom" diff --git a/crates/op-host-native/src/preview/tests_bindings.rs b/crates/op-host-native/src/preview/tests_bindings.rs index 5a0563041..8ca4fd19e 100644 --- a/crates/op-host-native/src/preview/tests_bindings.rs +++ b/crates/op-host-native/src/preview/tests_bindings.rs @@ -51,7 +51,8 @@ fn counter_doc() -> jian_ops_schema::PenDocument { #[test] fn enter_compiles_binding_sites() { let doc = counter_doc(); - let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + let session = + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); assert_eq!( session.binding_sites_len_for_test(), 1, @@ -70,8 +71,8 @@ fn invalid_binding_becomes_warning_not_error() { ] }"##; let doc = jian_ops_schema::load_str(src).expect("parse").value; - let session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter still ok"); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter still ok"); assert_eq!(session.binding_sites_len_for_test(), 0); assert!( session @@ -90,7 +91,8 @@ fn binding_content_resolves_on_enter() { // Even before any interaction, a bound text node must show the // expression's value over the doc-root default state. let doc = counter_doc(); - let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + let session = + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); let scene = session.preview_scene_for_test(); let label = find(&scene, "label").expect("label in scene"); assert_eq!(label.text.as_deref(), Some("Count: 2")); @@ -102,7 +104,7 @@ fn tap_event_updates_bound_text() { // bound label repaints with the new value. let doc = counter_doc(); let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); session.set_now_ms(0); let (x, y, w, h) = session.node_rect("sw").expect("switch rect"); session.dispatch_tap(x + w / 2.0, y + h / 2.0); @@ -120,7 +122,7 @@ fn bindings_do_not_mutate_document() { let before = serde_json::to_string(&doc).expect("before"); { let mut session = - PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0).expect("enter"); + PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false).expect("enter"); session.set_now_ms(0); let (x, y, w, h) = session.node_rect("sw").expect("switch rect"); session.dispatch_tap(x + w / 2.0, y + h / 2.0); diff --git a/crates/op-host-native/src/preview/tests_geometry_parity.rs b/crates/op-host-native/src/preview/tests_geometry_parity.rs new file mode 100644 index 000000000..bf1616ca3 --- /dev/null +++ b/crates/op-host-native/src/preview/tests_geometry_parity.rs @@ -0,0 +1,263 @@ +//! Preview ↔ design-canvas GEOMETRY parity tests. +//! +//! The user-visible bug: elements misalign when entering Preview. The +//! design canvas and the preview session build their `LayoutScene`s +//! through different paths, so any divergence between those paths +//! paints elements at different positions in the two modes. These +//! tests pin the invariant: for the SAME document, node bounds in the +//! preview scene must equal node bounds in the design scene. +//! +//! Covered divergences: +//! - Preserve-geometry documents (Figma imports set +//! `preserve_authored_geometry`): the design canvas paints authored +//! rects; preview must not silently re-run the flex solver. +//! - Legacy role-frame promotion: preview promotes `role=input` frames +//! to widget leaves before layout; the promoted tree must not shift +//! sibling geometry relative to the design canvas. +//! - Plain free-layout documents: control group — identical today, +//! must stay identical. + +#![cfg(test)] + +use super::PreviewSession; +use op_editor_ui::layout_scene::{LayoutScene, SceneNode}; + +fn default_theme() -> std::collections::BTreeMap { + std::collections::BTreeMap::new() +} + +fn load(src: &str) -> jian_ops_schema::PenDocument { + jian_ops_schema::load_str(src) + .expect("parse test doc") + .value +} + +fn find<'a>(scene: &'a LayoutScene, id: &str) -> Option<&'a SceneNode> { + scene.active_page().and_then(|p| p.find(id)) +} + +/// The design-canvas scene for `doc`, exactly as the editor paints it. +fn design_scene( + doc: &jian_ops_schema::PenDocument, + preserve_authored_geometry: bool, +) -> LayoutScene { + let mut state = op_editor_core::EditorState::from_document(doc.clone()); + state.editor_ui.preserve_authored_geometry = preserve_authored_geometry; + op_pen_loader::editor_state_to_layout_scene(&state) +} + +/// Assert `id` occupies the same rect in both scenes (±0.5 px). +fn assert_same_bounds(design: &LayoutScene, preview: &LayoutScene, id: &str) { + let d = find(design, id) + .unwrap_or_else(|| panic!("node {id} missing from design scene")) + .bounds; + let p = find(preview, id) + .unwrap_or_else(|| panic!("node {id} missing from preview scene")) + .bounds; + let close = |a: f32, b: f32| (a - b).abs() <= 0.5; + assert!( + close(d.origin.x, p.origin.x) + && close(d.origin.y, p.origin.y) + && close(d.size.x, p.size.x) + && close(d.size.y, p.size.y), + "node {id} misaligned in preview:\n design ({}, {}) {}x{}\n preview ({}, {}) {}x{}", + d.origin.x, + d.origin.y, + d.size.x, + d.size.y, + p.origin.x, + p.origin.y, + p.size.x, + p.size.y, + ); +} + +/// A Figma-import-shaped document: authored parent-local geometry that +/// deliberately DISAGREES with what the flex solver would compute (a +/// vertical auto-layout root whose second child is authored far from +/// the stacked position — exactly what Preserve-mode imports carry). +fn preserve_geometry_doc() -> jian_ops_schema::PenDocument { + load( + r##"{ + "version": "0.8.0", + "children": [ + { "type": "frame", "id": "root", "x": 0, "y": 0, + "width": 400, "height": 300, + "layout": "vertical", "gap": 8, "padding": 16, + "fill": [{"type":"solid","color":"#ffffff"}], + "children": [ + { "type": "rectangle", "id": "a", "x": 16, "y": 16, + "width": 60, "height": 20, + "fill": [{"type":"solid","color":"#ff0000"}] }, + { "type": "rectangle", "id": "b", "x": 120, "y": 210, + "width": 60, "height": 20, + "fill": [{"type":"solid","color":"#00ff00"}] } + ] } + ] + }"##, + ) +} + +#[test] +fn preview_matches_design_for_preserve_geometry_doc() { + // Text measurement must not race a concurrent font-registry + // mutation between the two scene builds (the import tests register + // real faces process-globally). + let _guard = crate::font_registry_test_support::lock(); + // Figma Preserve import: the design canvas honors authored rects + // (`preserve_authored_geometry = true` skips the flex pass). The + // preview must paint child `b` at the same authored spot — not at + // the flex-solved stacked position. + let doc = preserve_geometry_doc(); + let design = design_scene(&doc, true); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, true) + .expect("enter preview"); + let preview = session.preview_scene_for_test(); + assert_same_bounds(&design, &preview, "a"); + assert_same_bounds(&design, &preview, "b"); +} + +/// An AI-generation-shaped document: a vertical auto-layout root where a +/// legacy `role=input` frame (hug height, sized by its text child) is +/// followed by a sibling. Preview promotes the role frame to a leaf +/// `text_input` widget before layout; if the promoted leaf measures +/// differently than the frame+child, the sibling below shifts. +fn role_frame_doc() -> jian_ops_schema::PenDocument { + load( + r##"{ + "version": "0.8.0", + "children": [ + { "type": "frame", "id": "root", "x": 0, "y": 0, + "width": 400, "height": 600, + "layout": "vertical", "gap": 12, "padding": 16, + "fill": [{"type":"solid","color":"#ffffff"}], + "children": [ + { "type": "frame", "id": "emailField", "role": "input", + "width": 320, "padding": 12, + "layout": "horizontal", "gap": 8, + "stroke": {"color": "#d0d0d0", "thickness": 1}, + "cornerRadius": 8, + "children": [ + { "type": "text", "id": "ph", "content": "you@example.com", + "fontSize": 14, + "fill": [{"type":"solid","color":"#999999"}] } + ] }, + { "type": "text", "id": "below", "content": "Below the field", + "fontSize": 14, + "fill": [{"type":"solid","color":"#000000"}] } + ] } + ] + }"##, + ) +} + +#[test] +fn preview_matches_design_for_promoted_role_frame_doc() { + // Text measurement must not race a concurrent font-registry + // mutation between the two scene builds (the import tests register + // real faces process-globally). + let _guard = crate::font_registry_test_support::lock(); + // The design canvas lays out the UNPROMOTED tree (role frame with a + // text child); preview lays out the PROMOTED tree (leaf text_input). + // The field's own box and the sibling below it must not move. + let doc = role_frame_doc(); + let design = design_scene(&doc, false); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); + let preview = session.preview_scene_for_test(); + assert_same_bounds(&design, &preview, "emailField"); + assert_same_bounds(&design, &preview, "below"); +} + +#[test] +fn held_drag_stays_anchored_to_the_down_node() { + // Pointer capture across the scene→runtime remap: once a gesture + // anchors on a node whose scene and runtime rects diverge (e.g. a + // promoted widget's hug drift), every held Move and the Up must map + // through THAT rect pair — re-resolving per event would remap a + // drag through whatever node the pointer crosses, teleporting the + // drag and potentially activating a widget the pointer isn't + // visually over. The anchor is installed synthetically because the + // divergence is engine-dependent; the capture SEMANTICS are what + // this test pins. + use jian_core::gesture::pointer::PointerPhase; + use op_editor_ui::{Point2D, Rect}; + let doc = preserve_geometry_doc(); + let mut session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, true) + .expect("enter preview"); + + // Simulate a Down that anchored on a node painted at (120,210) + // 60x20 whose runtime copy sits at (16,44) 60x20. + let scene_rect = Rect { + origin: Point2D::new(120.0, 210.0), + size: Point2D::new(60.0, 20.0), + }; + let runtime_rect = Rect { + origin: Point2D::new(16.0, 44.0), + size: Point2D::new(60.0, 20.0), + }; + session.set_gesture_mapping_for_test(scene_rect, runtime_rect); + + // Held Move at the node's scene center maps to its runtime center. + let (mx, my) = session.resolve_runtime_point_for_test(150.0, 220.0, PointerPhase::Move); + assert!( + (mx - 46.0).abs() <= 0.5 && (my - 54.0).abs() <= 0.5, + "held Move should map through the anchored pair, got ({mx}, {my})" + ); + + // Held Move 20px PAST the node's right edge extrapolates through + // the same pair (x = 16 + (200-120)/60 * 60 = 96) — it must NOT + // re-resolve through the node under the new point (the root's + // identity map would yield 200). + let (px, py) = session.resolve_runtime_point_for_test(200.0, 220.0, PointerPhase::Move); + assert!( + (px - 96.0).abs() <= 0.5 && (py - 54.0).abs() <= 0.5, + "held Move past the edge must stay in the anchor's space, got ({px}, {py})" + ); + + // Up consumes the anchor; the next unpressed resolve at the same + // point goes back to fresh mapping (identity here: this fixture's + // runtime honors the authored rects, so scene == runtime). + session.resolve_runtime_point_for_test(200.0, 220.0, PointerPhase::Up); + let (hx, hy) = session.resolve_runtime_point_for_test(200.0, 220.0, PointerPhase::Hover); + assert!( + (hx - 200.0).abs() <= 0.5 && (hy - 220.0).abs() <= 0.5, + "after Up the anchor must be released (fresh mapping), got ({hx}, {hy})" + ); +} + +/// Control group: a plain hand-drawn free-layout document (absolute +/// rects, no auto-layout, no roles, no tokens) must render identically +/// in both modes. +#[test] +fn preview_matches_design_for_free_layout_doc() { + // Text measurement must not race a concurrent font-registry + // mutation between the two scene builds (the import tests register + // real faces process-globally). + let _guard = crate::font_registry_test_support::lock(); + let doc = load( + r##"{ + "version": "0.8.0", + "children": [ + { "type": "frame", "id": "root", "x": 40, "y": 40, + "width": 300, "height": 200, + "fill": [{"type":"solid","color":"#ffffff"}], + "children": [ + { "type": "rectangle", "id": "r1", "x": 60, "y": 60, + "width": 80, "height": 30, + "fill": [{"type":"solid","color":"#3366ff"}] }, + { "type": "text", "id": "t1", "x": 60, "y": 120, + "content": "Hand drawn", "fontSize": 16, + "fill": [{"type":"solid","color":"#000000"}] } + ] } + ] + }"##, + ); + let design = design_scene(&doc, false); + let session = PreviewSession::enter(&doc, (800.0, 600.0), &default_theme(), 0, false) + .expect("enter preview"); + let preview = session.preview_scene_for_test(); + assert_same_bounds(&design, &preview, "root"); + assert_same_bounds(&design, &preview, "r1"); + assert_same_bounds(&design, &preview, "t1"); +} diff --git a/crates/op-host-native/tests/preview_raster.rs b/crates/op-host-native/tests/preview_raster.rs index 64b7d16ae..0dd66f919 100644 --- a/crates/op-host-native/tests/preview_raster.rs +++ b/crates/op-host-native/tests/preview_raster.rs @@ -60,8 +60,8 @@ fn preview_paints_resolved_orange_at_the_swatch() { let doc = jian_ops_schema::load_str(src) .expect("parse var-color doc") .value; - let session = - PreviewSession::enter(&doc, (200.0, 200.0), &Default::default(), 0).expect("enter preview"); + let session = PreviewSession::enter(&doc, (200.0, 200.0), &Default::default(), 0, false) + .expect("enter preview"); // Raster surface cleared to a sentinel BLUE so "unpainted" pixels // are unmistakable (white would collide with common widget fills). diff --git a/crates/op-pen-loader/src/adapter.rs b/crates/op-pen-loader/src/adapter.rs index 304906484..0d8a96290 100644 --- a/crates/op-pen-loader/src/adapter.rs +++ b/crates/op-pen-loader/src/adapter.rs @@ -159,6 +159,79 @@ pub fn pen_document_to_payload_preserving_geometry(doc: &PenDocument) -> LoadedD } } +/// Convert a document pair into payloads for the Canvas Preview: the +/// PAINT tree comes from `paint_doc` (the promoted document, so widget +/// leaves carry their `SceneWidget` props) while GEOMETRY comes from +/// `layout_doc` (the unpromoted document, laid out exactly as the +/// design canvas lays it out — or, for preserve-geometry documents, +/// its authored rects). Promotion keeps each frame's id, so the +/// rect-by-id lookup lands for promoted widgets; the children a +/// promotion dropped simply don't appear in the paint tree. +/// +/// This is what makes Preview pixel-positions match the design canvas +/// BY CONSTRUCTION: the design canvas resolves geometry from the same +/// unpromoted tree through the same layout (or preserve) pass. +/// +/// Both documents must be structurally parallel (the promoted document +/// is loaded from the serialized unpromoted one), so their pages line +/// up index-for-index. +pub fn pen_documents_to_payload_for_preview( + paint_doc: &PenDocument, + layout_doc: &PenDocument, + preserve_authored_geometry: bool, +) -> LoadedDoc { + let rects_for = |roots: &[PenNode]| -> BTreeMap { + if preserve_authored_geometry { + crate::authored_geometry::rects_for_roots(roots) + } else { + let mut rects = BTreeMap::new(); + for root in roots { + compute_layout(root, &mut rects); + } + rects + } + }; + let build = |id: &str, name: &str, paint_roots: &[PenNode], layout_roots: &[PenNode]| { + let rects = rects_for(layout_roots); + let mut children: Vec = paint_roots + .iter() + .map(|n| node_to_payload(n, &rects)) + .collect(); + mark_root_frame_clips(&mut children); + PagePayload { + id: id.to_string(), + name: name.to_string(), + children, + } + }; + let pages: Vec = match (&paint_doc.pages, &layout_doc.pages) { + (Some(paint_pages), Some(layout_pages)) => paint_pages + .iter() + .zip(layout_pages.iter()) + .map(|(pp, lp)| build(&pp.id, &pp.name, &pp.children, &lp.children)) + .collect(), + _ if !paint_doc.children.is_empty() => vec![build( + "page-1", + paint_doc.name.as_deref().unwrap_or("Page 1"), + &paint_doc.children, + &layout_doc.children, + )], + _ => vec![PagePayload { + id: "n1".to_string(), + name: "Page 1".into(), + children: Vec::new(), + }], + }; + LoadedDoc { + payload: DocPayload { + version: 1, + active_page_index: 0, + pages, + var_table: crate::variables::VarTablePayload::default(), + }, + } +} + /// Copy `PenDocument.variables` + `.themes` into a shell-core /// `VariableTable`. Caller assigns the result to `Document.var_table` /// AFTER `apply_payload` (which clears it via Default). Lossless on diff --git a/crates/op-pen-loader/src/layout_scene.rs b/crates/op-pen-loader/src/layout_scene.rs index 74300f0a3..6695cd2e1 100644 --- a/crates/op-pen-loader/src/layout_scene.rs +++ b/crates/op-pen-loader/src/layout_scene.rs @@ -160,6 +160,51 @@ pub fn pen_document_to_layout_scene( } } +/// Build the Canvas Preview [`LayoutScene`]: paint tree from the +/// PROMOTED document (so legacy `role=input` frames render as live +/// widgets), geometry from the UNPROMOTED document laid out exactly as +/// the design canvas lays it out — honoring +/// `preserve_authored_geometry` for Figma Preserve imports. Node +/// positions in the returned scene therefore match the design canvas +/// by construction; only the paint representation differs. +/// +/// Both documents must already be ref/token-resolved (the preview +/// session prepares them before promotion), so no resolution passes +/// run here; the var table still folds the document's variables + +/// `active_theme` for any `$ref` fill lookups. +pub fn pen_document_to_layout_scene_for_preview( + paint_doc: &jian_ops_schema::PenDocument, + layout_doc: &jian_ops_schema::PenDocument, + preserve_authored_geometry: bool, + active_theme: &std::collections::BTreeMap, + active_page_index: usize, +) -> LayoutScene { + let payload: DocPayload = crate::adapter::pen_documents_to_payload_for_preview( + paint_doc, + layout_doc, + preserve_authored_geometry, + ) + .payload; + let mut var_table = crate::adapter::build_var_table(paint_doc); + var_table.active_theme = active_theme.clone(); + LayoutScene { + pages: payload + .pages + .iter() + .map(|page| ScenePage { + id: page.id.clone(), + name: page.name.clone(), + children: page + .children + .iter() + .map(|n| node_payload_to_scene(n, &var_table, 1.0)) + .collect(), + }) + .collect(), + active_page_index: active_page_index.min(payload.pages.len().saturating_sub(1)), + } +} + /// Convert one resolved [`NodePayload`] into a [`SceneNode`]. /// /// Geometry is copied straight through — `pen_document_to_payload` diff --git a/crates/op-pen-loader/src/lib.rs b/crates/op-pen-loader/src/lib.rs index 0062771f9..9e6ab3adf 100644 --- a/crates/op-pen-loader/src/lib.rs +++ b/crates/op-pen-loader/src/lib.rs @@ -66,7 +66,10 @@ pub mod variables; /// against the editor's variables + active theme. Builds the scene /// directly from the layout-resolved `DocPayload` — no intermediate /// shell-core `Document`. -pub use layout_scene::{editor_state_to_layout_scene, pen_document_to_layout_scene}; +pub use layout_scene::{ + editor_state_to_layout_scene, pen_document_to_layout_scene, + pen_document_to_layout_scene_for_preview, +}; /// Skips the layout-scene rebuild when the document / active theme / active page /// are unchanged — the hosts hold one and drive `refresh_layout_scene` through it. pub use scene_cache::SceneBuildCache; diff --git a/packages/op-web-sdk-react/package.json b/packages/op-web-sdk-react/package.json index 3748e4c79..feb723feb 100644 --- a/packages/op-web-sdk-react/package.json +++ b/packages/op-web-sdk-react/package.json @@ -1,16 +1,43 @@ { "name": "@zseven-w/op-web-sdk-react", - "version": "0.8.0", + "version": "0.8.1", "description": "React adapter for the OpenPencil read-only web viewer SDK", "license": "MIT", "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }, - "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", "test": "vitest run" }, - "dependencies": { "@zseven-w/op-web-sdk": "workspace:*" }, - "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0" }, - "devDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@testing-library/react": "^16.0.0", "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@zseven-w/op-web-sdk": "workspace:*" + }, + "peerDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@testing-library/react": "^16.0.0", + "tsup": "^8.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.0", + "jsdom": "^25.0.0" + } } diff --git a/packages/op-web-sdk-vue/package.json b/packages/op-web-sdk-vue/package.json index f06bc999a..54df528af 100644 --- a/packages/op-web-sdk-vue/package.json +++ b/packages/op-web-sdk-vue/package.json @@ -1,16 +1,40 @@ { "name": "@zseven-w/op-web-sdk-vue", - "version": "0.8.0", + "version": "0.8.1", "description": "Vue 3 adapter for the OpenPencil read-only web viewer SDK", "license": "MIT", "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } }, - "scripts": { "build": "tsup", "typecheck": "tsc --noEmit", "test": "vitest run" }, - "dependencies": { "@zseven-w/op-web-sdk": "workspace:*" }, - "peerDependencies": { "vue": "^3.4.0" }, - "devDependencies": { "vue": "^3.4.0", "@vue/test-utils": "^2.4.0", "@vitejs/plugin-vue": "^5.0.0", "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@zseven-w/op-web-sdk": "workspace:*" + }, + "peerDependencies": { + "vue": "^3.4.0" + }, + "devDependencies": { + "vue": "^3.4.0", + "@vue/test-utils": "^2.4.0", + "@vitejs/plugin-vue": "^5.0.0", + "tsup": "^8.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.0", + "jsdom": "^25.0.0" + } } diff --git a/packages/op-web-sdk/package.json b/packages/op-web-sdk/package.json index 98dd608b3..fd94a30d5 100644 --- a/packages/op-web-sdk/package.json +++ b/packages/op-web-sdk/package.json @@ -1,15 +1,22 @@ { "name": "@zseven-w/op-web-sdk", - "version": "0.8.0", + "version": "0.8.1", "description": "Read-only OpenPencil .op viewer SDK for the web (wasm-backed)", "license": "MIT", "type": "module", - "files": ["dist", "wasm"], + "files": [ + "dist", + "wasm" + ], "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.cjs" } + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } }, "scripts": { "build": "tsup", @@ -17,5 +24,10 @@ "test": "vitest run", "sync-wasm": "bash scripts/sync-wasm.sh" }, - "devDependencies": { "tsup": "^8.0.0", "typescript": "^5.7.2", "vitest": "^3.0.0", "jsdom": "^25.0.0" } + "devDependencies": { + "tsup": "^8.0.0", + "typescript": "^5.7.2", + "vitest": "^3.0.0", + "jsdom": "^25.0.0" + } }