fix(canvas): preview renders design-canvas geometry with pointer capture

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).
This commit is contained in:
Kayshen-X 2026-07-11 03:58:16 +08:00
parent d1943d7b3b
commit 35ce39634b
12 changed files with 885 additions and 237 deletions

View file

@ -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<Rect> {
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)
}
}

View file

@ -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<RootFrame>,
/// 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<String>,
@ -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<AppMode>,
/// 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<String, String>,
active_page_index: usize,
preserve_authored_geometry: bool,
) -> Result<Self, String> {
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

View file

@ -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"

View file

@ -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);

View file

@ -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<String, String> {
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");
}

View file

@ -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).

View file

@ -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<String, [f32; 4]> {
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<NodePayload> = 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<PagePayload> = 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

View file

@ -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<String, String>,
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`

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}