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:
parent
d1943d7b3b
commit
35ce39634b
312
crates/op-host-native/src/preview/input.rs
Normal file
312
crates/op-host-native/src/preview/input.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
263
crates/op-host-native/src/preview/tests_geometry_parity.rs
Normal file
263
crates/op-host-native/src/preview/tests_geometry_parity.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue